mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-29 08:38:06 +00:00
Emits ai.cost.multiplier.<driver>.<provider>:<model> before recording AI usage, so what a model costs to charge is policy an extension owns rather than a number in core. Nothing listening records the provider cost. MeteringService.withAiCostMultiplier(driver) returns a view of the service whose recording paths scale costOverride by the hook's answer; every AI driver hands that view to its providers, so all of them are covered without touching provider code.
411 lines
15 KiB
JavaScript
411 lines
15 KiB
JavaScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { metrics } from '@opentelemetry/api';
|
|
import {
|
|
POOL_ACQUIRE_TIMEOUT,
|
|
isNeverSentError,
|
|
isRetriableError,
|
|
isRolledBackError,
|
|
} from './retriableErrors.js';
|
|
|
|
const DEFAULT_MAX_QUEUE_SIZE = 1000;
|
|
const DEFAULT_FAILURE_THRESHOLD = 5;
|
|
const DEFAULT_COOLDOWN_MS = 5_000;
|
|
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
const ACQUIRE_ATTEMPTS = 3;
|
|
const ITEM_RETRY_ATTEMPTS = 2;
|
|
const RETRY_BASE_BACKOFF_MS = 100;
|
|
const FALLBACK_RETRY_CONCURRENCY = 8;
|
|
|
|
const meter = metrics.getMeter('puter-backend');
|
|
const enqueueDroppedCounter = meter.createCounter(
|
|
'sql_batcher.enqueue.dropped',
|
|
{
|
|
description:
|
|
'Items dropped from SQLBatcher queue at the high-water mark',
|
|
},
|
|
);
|
|
const enqueueRejectedCounter = meter.createCounter(
|
|
'sql_batcher.enqueue.rejected',
|
|
{ description: 'Items rejected because the SQLBatcher circuit is open' },
|
|
);
|
|
const flushFailureCounter = meter.createCounter('sql_batcher.flush.failed', {
|
|
description: 'SQLBatcher flush attempts that threw',
|
|
});
|
|
const fallbackInvocationsCounter = meter.createCounter(
|
|
'sql_batcher.fallback.invocations',
|
|
{
|
|
description:
|
|
'Times SQLBatcher fell back to per-item retry after a batch error',
|
|
},
|
|
);
|
|
const fallbackItemFailuresCounter = meter.createCounter(
|
|
'sql_batcher.fallback.item_failures',
|
|
{
|
|
description:
|
|
'Per-item failures observed during SQLBatcher per-item retry',
|
|
},
|
|
);
|
|
const fallbackItemRetriesCounter = meter.createCounter(
|
|
'sql_batcher.fallback.item_retries',
|
|
{
|
|
description:
|
|
'Transient per-item failures retried during SQLBatcher fallback',
|
|
},
|
|
);
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
export class SQLBatcher {
|
|
dbPool;
|
|
maxTimeInQueue;
|
|
maxBatchSize;
|
|
maxQueueSize;
|
|
failureThreshold;
|
|
cooldownMs;
|
|
poolLabel;
|
|
readOnly;
|
|
acquireTimeoutMs;
|
|
queue = [];
|
|
timeouts = [];
|
|
#consecutiveFailures = 0;
|
|
#lastFailureAt = 0;
|
|
#metricAttrs;
|
|
|
|
/**
|
|
* @param {object} dbPool Mysql2 pool
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.maxTimeInQueue] Ms an item may wait before flush
|
|
* @param {number} [opts.maxBatchSize] Items coalesced per flush
|
|
* @param {number} [opts.maxQueueSize] Drop-oldest high-water mark
|
|
* @param {number} [opts.failureThreshold] Consecutive failures to open the
|
|
* breaker
|
|
* @param {number} [opts.cooldownMs] Breaker open duration after last
|
|
* failure
|
|
* @param {'primary' | 'replica'} [opts.poolLabel] Role label on metrics; in
|
|
* single-node setups the 'replica' batcher shares the primary pool, so
|
|
* this reflects the read/write role rather than a physical instance
|
|
* @param {boolean} [opts.readOnly] This batcher only ever carries SELECTs,
|
|
* so any transient failure is safe to retry
|
|
* @param {number} [opts.acquireTimeoutMs] Max wait for a pooled connection;
|
|
* 0 disables the bound
|
|
*/
|
|
constructor(
|
|
dbPool,
|
|
{
|
|
maxTimeInQueue = 20,
|
|
maxBatchSize = 50,
|
|
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
|
|
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
|
|
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
poolLabel = 'primary',
|
|
readOnly = false,
|
|
acquireTimeoutMs = DEFAULT_ACQUIRE_TIMEOUT_MS,
|
|
} = {},
|
|
) {
|
|
this.dbPool = dbPool;
|
|
this.maxTimeInQueue = maxTimeInQueue;
|
|
this.maxBatchSize = maxBatchSize;
|
|
this.maxQueueSize = maxQueueSize;
|
|
this.failureThreshold = failureThreshold;
|
|
this.cooldownMs = cooldownMs;
|
|
this.poolLabel = poolLabel;
|
|
this.readOnly = readOnly;
|
|
this.acquireTimeoutMs = acquireTimeoutMs;
|
|
this.#metricAttrs = { pool: poolLabel };
|
|
}
|
|
|
|
async execute(sql, values) {
|
|
return this.query(sql, values);
|
|
}
|
|
|
|
promise() {
|
|
return this;
|
|
}
|
|
|
|
// The public error is deliberately opaque (no SQL, no internals), but
|
|
// `reason` distinguishes the load-shed path for logs and callers:
|
|
// breakerOpen | queueOverflow | connAcquire.
|
|
#createPublicBatchError(reason) {
|
|
const error = new Error('Database operation failed');
|
|
error.code = 'dbBatchFailed';
|
|
error.reason = reason;
|
|
return error;
|
|
}
|
|
|
|
// Open while we've seen `failureThreshold` consecutive flush failures and
|
|
// the cooldown window since the most recent failure hasn't elapsed. After
|
|
// cooldown a probe request is allowed through; success resets the counter.
|
|
#isBreakerOpen() {
|
|
if (this.#consecutiveFailures < this.failureThreshold) return false;
|
|
return Date.now() - this.#lastFailureAt < this.cooldownMs;
|
|
}
|
|
|
|
async query(sql, values) {
|
|
if (this.#isBreakerOpen()) {
|
|
enqueueRejectedCounter.add(1, this.#metricAttrs);
|
|
throw this.#createPublicBatchError('breakerOpen');
|
|
}
|
|
|
|
const { promise, resolve, reject } = Promise.withResolvers();
|
|
|
|
// Drop-oldest at the high-water mark. Bounds memory while preferring
|
|
// to flush the most recent work — older queued entries are likeliest
|
|
// to have already exceeded any caller-side timeout anyway.
|
|
while (this.queue.length >= this.maxQueueSize) {
|
|
const dropped = this.queue.shift();
|
|
dropped.reject(this.#createPublicBatchError('queueOverflow'));
|
|
enqueueDroppedCounter.add(1, this.#metricAttrs);
|
|
}
|
|
|
|
this.queue.push({
|
|
sql,
|
|
values,
|
|
resolve,
|
|
reject,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
if (this.queue.length >= this.maxBatchSize) {
|
|
this.flush(this.queue.splice(0, this.maxBatchSize));
|
|
} else if (this.queue.length === 1) {
|
|
this.timeouts.push(
|
|
setTimeout(() => {
|
|
this.flush(this.queue.splice(0, this.queue.length));
|
|
}, this.maxTimeInQueue),
|
|
);
|
|
}
|
|
|
|
return promise;
|
|
}
|
|
|
|
// Bounded wait for a pooled connection. Without a bound, a stalled
|
|
// database turns every flush into an indefinite hang — nothing fails,
|
|
// so neither the breaker nor callers' own timeouts ever engage.
|
|
#getConnectionWithTimeout() {
|
|
const acquire = this.dbPool.promise().getConnection();
|
|
if (!this.acquireTimeoutMs) return acquire;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
// A connection that arrives late must go back to the pool.
|
|
acquire.then(
|
|
(conn) => conn.release(),
|
|
() => {},
|
|
);
|
|
const error = new Error(
|
|
'Timed out acquiring database connection',
|
|
);
|
|
error.code = POOL_ACQUIRE_TIMEOUT;
|
|
reject(error);
|
|
}, this.acquireTimeoutMs);
|
|
|
|
acquire.then(
|
|
(conn) => {
|
|
if (timedOut) return;
|
|
clearTimeout(timer);
|
|
resolve(conn);
|
|
},
|
|
(err) => {
|
|
if (timedOut) return;
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
// Acquisition failures never sent a statement, so retrying is always
|
|
// safe regardless of what the batch contains.
|
|
async #acquireConnection() {
|
|
let lastError;
|
|
for (let attempt = 1; attempt <= ACQUIRE_ATTEMPTS; attempt++) {
|
|
try {
|
|
return await this.#getConnectionWithTimeout();
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt < ACQUIRE_ATTEMPTS) {
|
|
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
|
}
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
async flush(batch) {
|
|
const timeout = this.timeouts.shift();
|
|
if (timeout && !timeout._destroyed) {
|
|
clearTimeout(timeout);
|
|
}
|
|
if (batch.length === 0) return;
|
|
|
|
const query = `${batch.map((b) => b.sql.replace(/;+\s*$/, '')).join(';')}; SELECT 1`; // SELECT 1 forces mysql2 to return array
|
|
const values = batch.map((b) => b.values ?? []).flat();
|
|
|
|
let connection;
|
|
try {
|
|
connection = await this.#acquireConnection();
|
|
} catch (error) {
|
|
this.#consecutiveFailures++;
|
|
this.#lastFailureAt = Date.now();
|
|
flushFailureCounter.add(1, this.#metricAttrs);
|
|
console.warn(
|
|
'SQLBatcher could not acquire connection for flush:',
|
|
error,
|
|
);
|
|
for (const b of batch) {
|
|
b.reject(this.#createPublicBatchError('connAcquire'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Run the coalesced multi-statement inside an explicit transaction so
|
|
// a single bad statement (e.g. a duplicate-key INSERT) rolls back the
|
|
// whole batch atomically, leaving us free to re-run each item
|
|
// individually below. Without this, MySQL would commit every
|
|
// statement up to the failure point and a per-item retry would
|
|
// misreport already-committed inserts as duplicate-key failures.
|
|
//
|
|
// A read-only batch has nothing to roll back, and the wrapper is not
|
|
// free: BEGIN and COMMIT are round trips of their own, which triples
|
|
// the cost of a read against a pool in another region.
|
|
const wrapInTransaction = !this.readOnly;
|
|
let batchSucceeded = false;
|
|
try {
|
|
if (wrapInTransaction) await connection.beginTransaction();
|
|
const [results, fields] = await connection.query(query, values);
|
|
if (wrapInTransaction) await connection.commit();
|
|
batchSucceeded = true;
|
|
this.#consecutiveFailures = 0;
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const b = batch[i];
|
|
b.resolve([results[i], fields?.[i]]);
|
|
}
|
|
} catch (batchError) {
|
|
if (wrapInTransaction) {
|
|
try {
|
|
await connection.rollback();
|
|
} catch (rollbackError) {
|
|
console.warn('SQLBatcher rollback failed:', rollbackError);
|
|
}
|
|
}
|
|
console.warn(
|
|
'SQLBatcher batch failed; retrying items individually:',
|
|
batchError,
|
|
);
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
|
|
if (batchSucceeded) return;
|
|
|
|
// Per-item fallback. Nothing from the batch is live — it was rolled
|
|
// back, or carried only SELECTs — so re-running each item
|
|
// independently produces clean success/failure outcomes for each
|
|
// caller. Concurrency is capped to avoid briefly saturating the pool
|
|
// when a large batch fails.
|
|
flushFailureCounter.add(1, this.#metricAttrs);
|
|
fallbackInvocationsCounter.add(1, this.#metricAttrs);
|
|
|
|
const settled = new Array(batch.length);
|
|
let cursor = 0;
|
|
const workers = Array.from(
|
|
{ length: Math.min(FALLBACK_RETRY_CONCURRENCY, batch.length) },
|
|
async () => {
|
|
while (cursor < batch.length) {
|
|
const i = cursor++;
|
|
settled[i] = await this.#runFallbackItem(batch[i]);
|
|
}
|
|
},
|
|
);
|
|
await Promise.all(workers);
|
|
|
|
let anySucceeded = false;
|
|
let failureCount = 0;
|
|
for (let i = 0; i < batch.length; i++) {
|
|
const b = batch[i];
|
|
const r = settled[i];
|
|
if (r.ok) {
|
|
anySucceeded = true;
|
|
b.resolve(r.value);
|
|
} else {
|
|
failureCount++;
|
|
b.reject(r.error);
|
|
}
|
|
}
|
|
if (failureCount > 0) {
|
|
fallbackItemFailuresCounter.add(failureCount, this.#metricAttrs);
|
|
}
|
|
|
|
// Only escalate the breaker when the database itself looks unhealthy
|
|
// (no item got through). Row-level errors like duplicate-key are
|
|
// application concerns, not DB outages, and shouldn't trip it.
|
|
this.#lastFailureAt = Date.now();
|
|
if (anySucceeded) {
|
|
this.#consecutiveFailures = 0;
|
|
} else {
|
|
this.#consecutiveFailures++;
|
|
}
|
|
}
|
|
|
|
// Run one fallback item, retrying transient failures with backoff.
|
|
// A read-only batcher may retry anything transient; a batcher that
|
|
// carries writes only retries failures where the statement provably
|
|
// did not apply — either it never reached the server, or the server
|
|
// rolled it back itself. A write that died mid-flight may have
|
|
// committed, and re-running that one would double-apply.
|
|
//
|
|
// Lock contention lands in the second group and is worth retrying rather
|
|
// than surfacing: an item is a single statement, so a deadlock victim has
|
|
// been fully undone, and the caller sees an unhandled 500 for what the
|
|
// database is telling us to just run again.
|
|
async #runFallbackItem(b) {
|
|
let attempt = 0;
|
|
while (true) {
|
|
let connection;
|
|
try {
|
|
connection = await this.#acquireConnection();
|
|
} catch (error) {
|
|
return { ok: false, error };
|
|
}
|
|
try {
|
|
return {
|
|
ok: true,
|
|
value: await connection.query(b.sql, b.values ?? []),
|
|
};
|
|
} catch (error) {
|
|
const canRetry = this.readOnly
|
|
? isRetriableError(error) || isRolledBackError(error)
|
|
: isNeverSentError(error) || isRolledBackError(error);
|
|
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
|
|
return { ok: false, error };
|
|
}
|
|
attempt++;
|
|
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
|
|
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
|
|
} finally {
|
|
connection.release();
|
|
}
|
|
}
|
|
}
|
|
}
|