mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-21 20:55:55 +00:00
rework: change backend and backend extensions to use simpler code structure and patterns (#2815)
* fix: dynamodb health checks and client recreation (#2789) * wip: no nanoServices groundwork * feat: data clients in new shape * wip: auth and perms in new system * more wip * middlewaters mainly done * wip: fsv2 in new layout * old fs v2 migration * driver system * driver and old fs fixes * ai drivers wip * stream support * metering in ai chat driver * wip: new auth * rate limit and auth routes * captcha and anti csrf * fix: types * auth store * app logic * wip most other dricvers * fs * mostly kill all legacy stuff * fs finish * fix: redis usage * ai controller * driver cleanup * socket io in v2 * broadcast and crudq stuff * subdomains * notifcations and shares * fix bad syntaxes * auth wip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * extensions * extension setup * more routes * sql migrations and default services * home router * tier 7 * everything else * everything else * remaining missing bits * server health * logs * cleanup * deps * cleanup 2 * more cleanup 2 * boot * fix launch * config fix * move file * fix: tsconfig things * fix: extension loading * launching * fix: drivers * fix: others * fix: icons * fix: file uploads * fs fixes * fix: fs api * fix: dev-center * config * add back telemetry * lint stuff * husky hooks * fix: fs oss * fix: config migration * config migration * migrate scripts + replicate * runner * fix: merge defafult config * fix: default region * fix: api domain * fix paths in readfile * fix fs entry default s3 * NS: Remove Referral && Entri Service * dep cleanups * fix: static assets * fix: kv and perms * fix: driver registrations * fix: home mapping * fix: rao * adding back 500 alarm * fix: build paths * fix: fs and kv shapes * fix: kv shape * more kv coercing and ai chat matching format as prior * fix: private app gates * private app caches * fix: whole bunch of legacy shape issues * update template jsonc * fix caching partial oidc and fs signed paths * more oidc fixes * fix: wip * fix: private apps * admin route fixes * fix: last few things hopefully * claude uploads * fix security for app only routes * fix kv system namespace * stuff * fix: app and kv and suggested apps * fix:open item * fix: FS operations * fix: default app icons * add back token-read and WSL support * metering fixes * fix: fsEntry * perm scanners and implicators * proper download endpoint * fix: download * fix anti csrft on v2 * fix file extensions, app icons * fold in v1 fixes from origin/main into v2 equivalents Re-applies the v1 fixes that landed on origin/main into their v2 counterparts since the v1 files were deleted on DS/wip during the v2 migration. v1 commits referenced below. - SQLBatcher: flush immediately when queue hits maxBatchSize instead of racing the timer (v1 12f48238). - RedisClient: drop maxRetriesPerRequest from 2 to 1 to shrink failure window (v1b6776ab4). - ChatCompletionDriver: default minimumCredits to 1 when unset/zero so zero-cost precheck doesn't auto-pass (v136bd6073). - OpenAiImageProvider: add gpt-image-2 support — open-ended size rules, token-based cost estimator, arbitrary-size normalizer, isGpt prefix broadened to gpt-image- (v1f14f1bf4). models.ts auto-merged via rename detection. - AppStore: bump row cache TTL from 5m to 24h (v16b3196ed). Not ported: v1 app-object Redis cache (bdfa12b5/b886dde3) — v2's #toClient recomputes filetype_associations/created_from_origin per read; adding a second cache layer is a larger change for a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * remoe anti-csrf from auth routes that had not used them * more icon fixes * fix worker functionality * fix: app and subdomain es Co-authored-by: Copilot <copilot@github.com> * fix PUT-761 * fix: PUT-748 * fix: rename fsService * Add security back to WorkerDriver * Migrate worker from fsEntry to fs. Fix cache issue * remove ability to create symlinks * strict webdav acl * require auth for wisp * chore: service renames * Add metering back to puter peer api * fix: PUT-760 PUT-749 * fix: PUT-746 * fix: peer cost Co-authored-by: Copilot <copilot@github.com> * fix: 771 * change order of peer controller * fix: create appdata folder for app on get auth token * fix: align delete site and list sites * delete: putility * fix subdomains * Add support for tilde in subdomains, fix subdomain update * cleanup PeerController.ts and fix billing oversight (#2844) * fix: PUT-786 * fix: bugs * fix: issues with multiple subdomain queries, or permission checks * fix: harden response shapes to not contain uneeded fields * fix: move state to redis * fix: missing kv methods + better sec Co-authored-by: Copilot <copilot@github.com> * fix: subdomainStore limit * fix: missing path resolution Co-authored-by: Copilot <copilot@github.com> * fs fixes * fix: undef error * fix fs + cleanup * fix: npm audit fixes * heal path entries where missing Co-authored-by: Copilot <copilot@github.com> * fix: caching Co-authored-by: Copilot <copilot@github.com> * fix: cache inconsistencies Co-authored-by: Copilot <copilot@github.com> * fix: app driver metadata Co-authored-by: Copilot <copilot@github.com> * remove extraneous comma * fix: associated app icons * fix: bad tool call * Add validation to WorkerDriver#getFilePaths * misc fs and auth issues Co-authored-by: Copilot <copilot@github.com> * fix: oidc errors Co-authored-by: Copilot <copilot@github.com> * fix: PUT-797 * fix: legacy appdata_app Co-authored-by: Copilot <copilot@github.com> * fix: add alert logs Co-authored-by: Copilot <copilot@github.com> * fix: error handling * Disable sharecontroller * fix: remove private user identifier for ai * fix: private app fixes * Add backback signup_server * fix: completionId size Co-authored-by: Copilot <copilot@github.com> * fix: revalidate path for oidc * fix: revalidate path for oidc * fix: email validation Co-authored-by: Copilot <copilot@github.com> * fix: user create query * fix: middleware extensions Co-authored-by: Copilot <copilot@github.com> * use x-forwarded-for for req ip forwarded * fix: missing last_activity ts * feat: add cache broadcast to subdomains * fix: update config typing --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: ProgrammerIn-wonderland <3838shah@gmail.com> Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Nariman Jelveh <nj@puter.com> Co-authored-by: velzie <velzie@velzie.rip>
This commit is contained in:
co-authored by
Claude Opus 4.7
Copilot
ProgrammerIn-wonderland
Nariman Jelveh
velzie
parent
255dde9e96
commit
d4d78ac7db
@@ -0,0 +1,401 @@
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const RETRIABLE_ERROR_CODES = new Set([
|
||||
'PROTOCOL_CONNECTION_LOST',
|
||||
'PROTOCOL_SEQUENCE_TIMEOUT',
|
||||
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
|
||||
'ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
'EPIPE',
|
||||
'ECONNREFUSED',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
|
||||
const RETRIABLE_ERROR_MESSAGES = [
|
||||
'Connection lost',
|
||||
'read ECONNRESET',
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
type PoolConfig = Parameters<typeof createPool>[0];
|
||||
|
||||
enum Configuration {
|
||||
SINGLE,
|
||||
REPLICA,
|
||||
}
|
||||
|
||||
export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
override readonly engineName = 'mysql';
|
||||
|
||||
private primaryPool!: Pool;
|
||||
private replicaPool!: Pool;
|
||||
private db!: SQLBatcher;
|
||||
private dbReplica!: SQLBatcher;
|
||||
private configuration = Configuration.SINGLE;
|
||||
private shutdownStarted = false;
|
||||
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
const dbConf = this.config.database!;
|
||||
|
||||
this.primaryPool = this.createPool({
|
||||
host: dbConf.host ?? '127.0.0.1',
|
||||
port: dbConf.port ?? 3306,
|
||||
user: dbConf.user ?? 'root',
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
console.log('[mysql] connected to primary');
|
||||
|
||||
this.db = new SQLBatcher(this.primaryPool, 40, 10);
|
||||
|
||||
if (dbConf.replica) {
|
||||
this.replicaPool = this.createPool(dbConf.replica);
|
||||
this.configuration = Configuration.REPLICA;
|
||||
console.log('[mysql] connected to read-replica');
|
||||
} else {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.configuration = Configuration.SINGLE;
|
||||
}
|
||||
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 10);
|
||||
}
|
||||
|
||||
override async onServerPrepareShutdown(): Promise<void> {
|
||||
if (this.shutdownStarted) return;
|
||||
this.shutdownStarted = true;
|
||||
|
||||
// Allow in-flight queries to drain before closing pools
|
||||
const drainMs = 60_000;
|
||||
console.log(
|
||||
`[mysql] draining in-flight queries (${drainMs}ms) before closing pools`,
|
||||
);
|
||||
|
||||
this.shutdownTimer = setTimeout(() => {
|
||||
this.shutdownTimer = null;
|
||||
this.closeCurrentPools('drain').catch((e) =>
|
||||
console.error('[mysql] error closing pools after drain', e),
|
||||
);
|
||||
}, drainMs);
|
||||
|
||||
if (typeof this.shutdownTimer.unref === 'function') {
|
||||
this.shutdownTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
override async onServerShutdown(): Promise<void> {
|
||||
if (this.shutdownTimer) {
|
||||
clearTimeout(this.shutdownTimer);
|
||||
this.shutdownTimer = null;
|
||||
}
|
||||
await this.closeCurrentPools('shutdown');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.dbReplica.execute(query, params);
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.db.execute(query, params);
|
||||
if (!result) return [];
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<WriteResult> {
|
||||
const result = await this.db.execute(query, params);
|
||||
const header = result[0] as {
|
||||
insertId?: number;
|
||||
affectedRows?: number;
|
||||
};
|
||||
return {
|
||||
insertId: header.insertId ?? 0,
|
||||
anyRowsAffected: (header.affectedRows ?? 0) > 0,
|
||||
};
|
||||
}
|
||||
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
// Bypass the SQLBatcher: it coalesces queries from unrelated callers
|
||||
// into a single multi-statement string, which is incompatible with
|
||||
// wrapping a transaction around just *our* statements. Acquire a
|
||||
// dedicated connection so BEGIN/COMMIT/ROLLBACK only scope `entries`.
|
||||
const conn = await this.primaryPool.promise().getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
try {
|
||||
for (const { statement, values } of entries) {
|
||||
await conn.execute(statement, values);
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
// Run both reads in parallel — prefer replica when it returns rows,
|
||||
// otherwise fall back to primary to handle replication lag.
|
||||
const primaryPromise = this.db.execute(query, params);
|
||||
try {
|
||||
const replicaResult = await this.dbReplica.execute(query, params);
|
||||
if (
|
||||
Array.isArray(replicaResult?.[0]) &&
|
||||
(replicaResult[0] as unknown[]).length > 0
|
||||
) {
|
||||
primaryPromise.catch(() => {}); // suppress unhandled rejection
|
||||
return replicaResult[0] as Record<string, unknown>[];
|
||||
}
|
||||
} catch {
|
||||
// fall through to primary
|
||||
}
|
||||
|
||||
const primaryResult = await primaryPromise;
|
||||
return (primaryResult?.[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Pool management
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private createPool(poolConfig: PoolConfig): Pool {
|
||||
return createPool({
|
||||
maxPreparedStatements: 900,
|
||||
...poolConfig,
|
||||
multipleStatements: true,
|
||||
} as PoolConfig);
|
||||
}
|
||||
|
||||
/** Reinitialize the primary pool (e.g. after a health-check failure). */
|
||||
reinitPrimary(): void {
|
||||
if (this.shutdownStarted) return;
|
||||
|
||||
const dbConf = this.config.database!;
|
||||
const previous = this.primaryPool;
|
||||
this.primaryPool = this.createPool({
|
||||
host: dbConf.host ?? '127.0.0.1',
|
||||
port: dbConf.port ?? 3306,
|
||||
user: dbConf.user ?? 'root',
|
||||
password: dbConf.password ?? '',
|
||||
database: dbConf.database ?? 'puter',
|
||||
});
|
||||
this.db = new SQLBatcher(this.primaryPool, 40, 10);
|
||||
|
||||
if (this.configuration === Configuration.SINGLE) {
|
||||
this.replicaPool = this.primaryPool;
|
||||
this.dbReplica = new SQLBatcher(this.primaryPool, 10, 10);
|
||||
}
|
||||
|
||||
if (previous && previous !== this.primaryPool) {
|
||||
this.closePool(previous, 'reinit:primary').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Reinitialize the replica pool. */
|
||||
reinitReplica(): void {
|
||||
if (this.shutdownStarted || !this.config.database?.replica) return;
|
||||
|
||||
const previous = this.replicaPool;
|
||||
this.replicaPool = this.createPool(this.config.database.replica);
|
||||
this.dbReplica = new SQLBatcher(this.replicaPool, 10);
|
||||
|
||||
if (
|
||||
previous &&
|
||||
previous !== this.replicaPool &&
|
||||
previous !== this.primaryPool
|
||||
) {
|
||||
this.closePool(previous, 'reinit:replica').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Retry helpers (for health checks or resilient reads)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
static isRetriableError(error: unknown): boolean {
|
||||
const code = (error as { code?: string })?.code;
|
||||
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
|
||||
|
||||
const msg = String((error as Error)?.message ?? '');
|
||||
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
|
||||
}
|
||||
|
||||
async readWithRetry(
|
||||
label: string,
|
||||
operation: () => Promise<unknown[]>,
|
||||
opts?: {
|
||||
maxAttempts?: number;
|
||||
baseBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
jitterRatio?: number;
|
||||
},
|
||||
): Promise<unknown[]> {
|
||||
const maxAttempts = opts?.maxAttempts ?? 3;
|
||||
const baseBackoffMs = opts?.baseBackoffMs ?? 100;
|
||||
const maxBackoffMs = opts?.maxBackoffMs ?? 500;
|
||||
const jitterRatio = opts?.jitterRatio ?? 0.2;
|
||||
|
||||
let attempt = 1;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (this.shutdownStarted) throw error;
|
||||
if (
|
||||
attempt >= maxAttempts ||
|
||||
!MySQLDatabaseClient.isRetriableError(error)
|
||||
)
|
||||
throw error;
|
||||
|
||||
const raw = baseBackoffMs * 2 ** (attempt - 1);
|
||||
const capped = Math.min(maxBackoffMs, raw);
|
||||
const window = Math.round(capped * jitterRatio);
|
||||
const jitter =
|
||||
window === 0
|
||||
? 0
|
||||
: Math.floor(Math.random() * (window * 2 + 1)) - window;
|
||||
const delay = Math.max(0, capped + jitter);
|
||||
|
||||
console.warn(
|
||||
`[${label}] transient mysql error (${(error as { code?: string })?.code ?? 'unknown'}); retry ${attempt + 1}/${maxAttempts} in ${delay}ms`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Internal pool lifecycle
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private async closePool(
|
||||
pool: Pool,
|
||||
label: string,
|
||||
timeoutMs: number | null = null,
|
||||
): Promise<void> {
|
||||
if (!pool) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const finish = (err?: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
if (timeoutMs !== null) {
|
||||
timer = setTimeout(() => {
|
||||
console.warn(
|
||||
`[mysql] timed out closing pool (${label}); forcing`,
|
||||
);
|
||||
this.forceDestroyConnections(pool, `${label}:timeout`);
|
||||
finish();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
pool.end((err) => finish(err));
|
||||
} catch (err) {
|
||||
finish(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private forceDestroyConnections(pool: Pool, label: string): void {
|
||||
// mysql2 internal — _allConnections is a CircularBuffer
|
||||
const all = (
|
||||
pool as unknown as {
|
||||
_allConnections?: {
|
||||
forEach: (fn: (c: { destroy: () => void }) => void) => void;
|
||||
};
|
||||
}
|
||||
)._allConnections;
|
||||
if (!all || typeof all.forEach !== 'function') return;
|
||||
|
||||
let count = 0;
|
||||
all.forEach((conn) => {
|
||||
try {
|
||||
conn.destroy();
|
||||
count++;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
if (count > 0)
|
||||
console.warn(
|
||||
`[mysql] force-closed ${count} connections (${label})`,
|
||||
);
|
||||
}
|
||||
|
||||
private async closeCurrentPools(reason: string): Promise<void> {
|
||||
const timeoutMs = reason.startsWith('signal:') ? 45_000 : null;
|
||||
const tasks: Promise<void>[] = [];
|
||||
|
||||
if (this.primaryPool) {
|
||||
tasks.push(
|
||||
this.closePool(
|
||||
this.primaryPool,
|
||||
`${reason}:primary`,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.replicaPool && this.replicaPool !== this.primaryPool) {
|
||||
tasks.push(
|
||||
this.closePool(
|
||||
this.replicaPool,
|
||||
`${reason}:replica`,
|
||||
timeoutMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user