mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-24 11:24:10 +01:00
* 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>
122 lines
3.8 KiB
TypeScript
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 };
|
|
}
|