LibreChat/packages/data-schemas/src/utils/retry.ts
Danny Avila bbc326a190
🐘 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-21 20:47:15 -05:00

122 lines
3.8 KiB
TypeScript

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 };
}