LibreChat/packages/data-schemas/src/utils/retry.ts

123 lines
3.8 KiB
TypeScript
Raw Normal View History

🐘 feat: FerretDB Compatibility (#11769) * feat: replace unsupported MongoDB aggregation operators for FerretDB compatibility Replace $lookup, $unwind, $sample, $replaceRoot, and $addFields aggregation stages which are unsupported on FerretDB v2.x (postgres-documentdb backend). - Prompt.js: Replace $lookup/$unwind/$project pipelines with find().select().lean() + attachProductionPrompts() batch helper. Replace $group/$replaceRoot/$sample in getRandomPromptGroups with distinct() + Fisher-Yates shuffle. - Agent/Prompt migration scripts: Replace $lookup anti-join pattern with distinct() + $nin two-step queries for finding un-migrated resources. All replacement patterns verified against FerretDB v2.7.0. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: use $pullAll for simple array removals, fix memberIds type mismatches Replace $pull with $pullAll for exact-value scalar array removals. Both operators work on MongoDB and FerretDB, but $pullAll is more explicit for exact matching (no condition expressions). Fix critical type mismatch bugs where ObjectId values were used against String[] memberIds arrays in Group queries: - config/delete-user.js: use string uid instead of ObjectId user._id - e2e/setup/cleanupUser.ts: convert userId.toString() before query Harden PermissionService.bulkUpdateResourcePermissions abort handling to prevent crash when abortTransaction is called after commitTransaction. All changes verified against FerretDB v2.7.0 and MongoDB Memory Server. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: harden transaction support probe for FerretDB compatibility Commit the transaction before aborting in supportsTransactions probe, and wrap abortTransaction in try-catch to prevent crashes when abort is called after a successful commit (observed behavior on FerretDB). Co-authored-by: Cursor <cursoragent@cursor.com> * feat: add FerretDB compatibility test suite, retry utilities, and CI config Add comprehensive FerretDB integration test suite covering: - $pullAll scalar array operations - $pull with subdocument conditions - $lookup replacement (find + manual join) - $sample replacement (distinct + Fisher-Yates) - $bit and $bitsAllSet operations - Migration anti-join pattern - Multi-tenancy (useDb, scaling, write amplification) - Sharding proof-of-concept - Production operations (backup/restore, schema migration, deadlock retry) Add production retryWithBackoff utility for deadlock recovery during concurrent index creation on FerretDB/DocumentDB backends. Add UserController.spec.js tests for deleteUserController (runs in CI). Configure jest and eslint to isolate FerretDB tests from CI pipelines: - packages/data-schemas/jest.config.mjs: ignore misc/ directory - eslint.config.mjs: ignore packages/data-schemas/misc/ Include Docker Compose config for local FerretDB v2.7 + postgres-documentdb, dedicated jest/tsconfig for the test files, and multi-tenancy findings doc. Co-authored-by: Cursor <cursoragent@cursor.com> * style: brace formatting in aclEntry.ts modifyPermissionBits Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: reorganize retry utilities and update imports - Moved retryWithBackoff utility to a new file `retry.ts` for better structure. - Updated imports in `orgOperations.ferretdb.spec.ts` to reflect the new location of retry utilities. - Removed old import statement for retryWithBackoff from index.ts to streamline exports. * test: add $pullAll coverage for ConversationTag and PermissionService Add integration tests for deleteConversationTag verifying $pullAll removes tags from conversations correctly, and for syncUserEntraGroupMemberships verifying $pullAll removes user from non-matching Entra groups while preserving local group membership. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 02:14:34 -05:00
import logger from '~/config/winston';
interface RetryOptions {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
jitter?: boolean;
retryableErrors?: string[];
onRetry?: (error: Error, attempt: number, delayMs: number) => void;
}
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'onRetry'>> = {
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 10_000,
jitter: true,
retryableErrors: ['deadlock', 'lock timeout', 'write conflict', 'ECONNRESET'],
};
/**
* Executes an async operation with exponential backoff + jitter retry
* on transient errors (deadlocks, connection resets, lock timeouts).
*
* Designed for FerretDB/DocumentDB operations where concurrent index
* creation or bulk writes can trigger PostgreSQL-level deadlocks.
*/
export async function retryWithBackoff<T>(
operation: () => Promise<T>,
label: string,
options: RetryOptions = {},
): Promise<T | undefined> {
const {
maxAttempts = DEFAULT_OPTIONS.maxAttempts,
baseDelayMs = DEFAULT_OPTIONS.baseDelayMs,
maxDelayMs = DEFAULT_OPTIONS.maxDelayMs,
jitter = DEFAULT_OPTIONS.jitter,
retryableErrors = DEFAULT_OPTIONS.retryableErrors,
} = options;
if (maxAttempts < 1 || baseDelayMs < 0 || maxDelayMs < 0) {
throw new Error(
`[retryWithBackoff] Invalid options: maxAttempts must be >= 1, delays must be non-negative`,
);
}
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (err: unknown) {
const message = (err as Error)?.message ?? String(err);
const isRetryable = retryableErrors.some((pattern) =>
message.toLowerCase().includes(pattern.toLowerCase()),
);
if (!isRetryable || attempt === maxAttempts) {
logger.error(
`[retryWithBackoff] ${label} failed permanently after ${attempt} attempt(s): ${message}`,
);
throw err;
}
const exponentialDelay = baseDelayMs * Math.pow(2, attempt - 1);
const jitterMs = jitter ? Math.random() * baseDelayMs : 0;
const delayMs = Math.min(exponentialDelay + jitterMs, maxDelayMs);
logger.warn(
`[retryWithBackoff] ${label} attempt ${attempt}/${maxAttempts} failed (${message}), retrying in ${Math.round(delayMs)}ms`,
);
if (options.onRetry) {
const normalizedError = err instanceof Error ? err : new Error(String(err));
options.onRetry(normalizedError, attempt, delayMs);
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
/**
* Creates all indexes for a Mongoose model with deadlock retry.
* Use this instead of raw `model.createIndexes()` on FerretDB.
*/
export async function createIndexesWithRetry(
model: { createIndexes: () => Promise<unknown>; modelName: string },
options: RetryOptions = {},
): Promise<void> {
await retryWithBackoff(
() => model.createIndexes() as Promise<unknown>,
`createIndexes(${model.modelName})`,
options,
);
}
/**
* Initializes all collections and indexes for a set of models on a connection,
* with per-model deadlock retry. Models are processed sequentially to minimize
* contention on the DocumentDB catalog.
*/
export async function initializeOrgCollections(
models: Record<
string,
{
createCollection: () => Promise<unknown>;
createIndexes: () => Promise<unknown>;
modelName: string;
}
>,
options: RetryOptions = {},
): Promise<{ totalMs: number; perModel: Array<{ name: string; ms: number }> }> {
const perModel: Array<{ name: string; ms: number }> = [];
const t0 = Date.now();
for (const model of Object.values(models)) {
const modelStart = Date.now();
await model.createCollection();
await createIndexesWithRetry(model, options);
perModel.push({ name: model.modelName, ms: Date.now() - modelStart });
}
return { totalMs: Date.now() - t0, perModel };
}