LibreChat/packages/data-schemas/misc/ferretdb/ferretdb-multitenancy-plan.md
Danny Avila 18f4e47afd
🐘 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-03-10 23:15:52 -04:00

204 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FerretDB Multi-Tenancy Plan
## Status: Active Investigation
## Goal
Database-per-org data isolation using FerretDB (PostgreSQL-backed) with horizontal sharding across multiple FerretDB+Postgres pairs. MongoDB and AWS DocumentDB are not options.
---
## Findings
### 1. FerretDB Architecture (DocumentDB Backend)
FerretDB with `postgres-documentdb` does **not** create separate PostgreSQL schemas per MongoDB database. All data lives in a single `documentdb_data` PG schema:
- Each MongoDB collection → `documents_<id>` + `retry_<id>` table pair
- Catalog tracked in `documentdb_api_catalog.collections` and `.collection_indexes`
- `mongoose.connection.useDb('org_X')` creates a logical database in DocumentDB's catalog
**Implication**: No PG-level schema isolation, but logical isolation is enforced by FerretDB's wire protocol layer. Backup/restore must go through FerretDB, not raw `pg_dump`.
### 2. Schema & Index Compatibility
All 29 LibreChat Mongoose models and 98 custom indexes work on FerretDB v2.7.0:
| Index Type | Count | Status |
|---|---|---|
| Sparse + unique | 9 (User OAuth IDs) | Working |
| TTL (expireAfterSeconds) | 8 models | Working |
| partialFilterExpression | 2 (File, Group) | Working |
| Compound unique | 5+ | Working |
| Concurrent creation | All 29 models | No deadlock (single org) |
### 3. Scaling Curve (Empirically Tested)
| Orgs | Collections | Catalog Indexes | Data Tables | pg_class | Init/org | Query avg | Query p95 |
|------|-------------|-----------------|-------------|----------|----------|-----------|-----------|
| 10 | 450 | 1,920 | 900 | 5,975 | 501ms | 1.03ms | 1.44ms |
| 50 | 1,650 | 7,040 | 3,300 | 20,695 | 485ms | 1.00ms | 1.46ms |
| 100 | 3,150 | 13,440 | 6,300 | 39,095 | 483ms | 0.83ms | 1.13ms |
**Key finding**: Init time and query latency are flat through 100 orgs. No degradation.
### 4. Write Amplification
User model (11+ indexes) vs zero-index collection: **1.11x** — only 11% overhead. DocumentDB's JSONB index management is efficient.
### 5. Sharding PoC
Tenant router proven with:
- Pool assignment with capacity limits (fill-then-spill)
- Warm cache routing overhead: **0.001ms** (sub-microsecond)
- Cold routing (DB lookup + connection + model registration): **6ms**
- Cross-pool data isolation confirmed
- Express middleware pattern (`req.getModel('User')`) works transparently
### 6. Scaling Thresholds
| Org Count | Postgres Instances | Notes |
|-----------|-------------------|-------|
| 1300 | 1 | Default config |
| 300700 | 1 | Tune autovacuum, PgBouncer, shared_buffers |
| 7001,000 | 1-2 | Split when monitoring signals pressure |
| 1,000+ | N / ~500 each | One FerretDB+Postgres pair per ~500 orgs |
### 7. Deadlock Behavior
- **Single org, concurrent index creation**: No deadlock (DocumentDB handles it)
- **Bulk provisioning (10 orgs sequential)**: Deadlock occurred on Pool B, recovered via retry
- **Production requirement**: Exponential backoff + jitter retry on `createIndexes()`
---
## Open Items
### A. Production Deadlock Retry ✅
- [x] Build `retryWithBackoff` utility with exponential backoff + jitter
- [x] Integrate into `initializeOrgCollections` and `migrateOrg` scripts
- [x] Tested against FerretDB — real deadlocks detected and recovered:
- `retry_4` hit a deadlock on `createIndexes(User)`, recovered via backoff (1,839ms total)
- `retry_5` also hit retry path (994ms vs ~170ms clean)
- Production utility at `packages/data-schemas/src/utils/retryWithBackoff.ts`
### B. Per-Org Backup/Restore ✅
- [x] `mongodump`/`mongorestore` CLI not available — tested programmatic driver-level approach
- [x] **Backup**: `listCollections()``find({}).toArray()` per collection → in-memory `OrgBackup` struct
- [x] **Restore**: `collection.insertMany(docs)` per collection into fresh org database
- [x] **BSON type preservation verified**: ObjectId, Date, String all round-trip correctly
- [x] **Data integrity verified**: `_id` values, field values, document counts match exactly
- [x] **Performance**: Backup 24ms, Restore 15ms (8 docs across 29 collections)
- [x] Scales linearly with document count — no per-collection overhead beyond the query
### C. Schema Migration Across Orgs ✅
- [x] `createIndexes()` is idempotent — re-init took 86ms with 12 indexes unchanged
- [x] **New collection propagation**: Added `AuditLog` collection with 4 indexes to 5 orgs — 109ms total
- [x] **New index propagation**: Added compound `{username:1, createdAt:-1}` index to `users` across 5 orgs — 22ms total
- [x] **Full migration run**: 5 orgs × 29 models = 88ms/org average (with deadlock retry)
- [x] **Data preservation confirmed**: All existing user data intact after migration
- [x] Extrapolating: 1,000 orgs × 88ms/org = ~88 seconds for a full migration sweep
---
## Test Files
| File | Purpose |
|---|---|
| `packages/data-schemas/src/methods/multiTenancy.ferretdb.spec.ts` | 5-phase benchmark (useDb mapping, indexes, scaling, write amp, shared collection) |
| `packages/data-schemas/src/methods/sharding.ferretdb.spec.ts` | Sharding PoC (router, assignment, isolation, middleware pattern) |
| `packages/data-schemas/src/methods/orgOperations.ferretdb.spec.ts` | Production operations (backup/restore, migration, deadlock retry) |
| `packages/data-schemas/src/utils/retryWithBackoff.ts` | Production retry utility |
## Docker
| File | Purpose |
|---|---|
| `docker-compose.ferretdb.yml` | Single FerretDB + Postgres (dev/test) |
---
## Detailed Empirical Results
### Deadlock Retry Behavior
The `retryWithBackoff` utility was exercised under real FerretDB load. Key observations:
| Scenario | Attempts | Total Time | Notes |
|---|---|---|---|
| Clean org init (no contention) | 1 | 165-199ms | Most orgs complete in one shot |
| Deadlock on User indexes | 2 | 994ms | Single retry recovers cleanly |
| Deadlock with compounding retries | 2-3 | 1,839ms | Worst case in 5-org sequential batch |
The `User` model (11+ indexes including 9 sparse unique) is the most deadlock-prone collection. The retry utility's exponential backoff with jitter (100ms base, 10s cap) handles this gracefully.
### Backup/Restore Round-Trip
Tested with a realistic org containing 4 populated collections:
| Operation | Time | Details |
|---|---|---|
| Backup (full org) | 24ms | 8 docs across 29 collections (25 empty) |
| Restore (to new org) | 15ms | Including `insertMany()` for each collection |
| Index re-creation | ~500ms | Separate `initializeOrgCollections` call |
Round-trip verified:
- `_id` (ObjectId) preserved exactly
- `createdAt` / `updatedAt` (Date) preserved
- String, Number, ObjectId ref fields preserved
- Document counts match source
For larger orgs (thousands of messages/conversations), backup time scales linearly with document count. The bottleneck is network I/O to FerretDB, not serialization.
### Schema Migration Performance
| Operation | Time | Per Org |
|---|---|---|
| Idempotent re-init (no changes) | 86ms | 86ms |
| New collection + 4 indexes | 109ms | 22ms/org |
| New compound index on users | 22ms | 4.4ms/org |
| Full migration sweep (29 models) | 439ms | 88ms/org |
Migration is safe to run while the app is serving traffic — `createIndexes` and `createCollection` are non-blocking operations that don't lock existing data.
### 5-Org Provisioning with Production Retry
```
retry_1: 193ms (29 models) — clean
retry_2: 199ms (29 models) — clean
retry_3: 165ms (29 models) — clean
retry_4: 1839ms (29 models) — deadlock on User indexes, recovered
retry_5: 994ms (29 models) — deadlock on User indexes, recovered
Total: 3,390ms for 5 orgs (678ms avg, but 165ms median)
```
---
## Production Recommendations
### 1. Org Provisioning
Use `initializeOrgCollections()` from `packages/data-schemas/src/utils/retryWithBackoff.ts` for all new org setup. Process orgs in batches of 10 with `Promise.all()` to parallelize across pools while minimizing per-pool contention.
### 2. Backup Strategy
Implement driver-level backup (not `mongodump`):
- Enumerate collections via `listCollections()`
- Stream documents via `find({}).batchSize(1000)` for large collections
- Write to object storage (S3/GCS) as NDJSON per collection
- Restore via `insertMany()` in batches of 1,000
### 3. Schema Migrations
Run `migrateAllOrgs()` as a deployment step:
- Enumerate all org databases from the assignment table
- For each org: register models, `createCollection()`, `createIndexesWithRetry()`
- `createIndexes()` is idempotent — safe to re-run
- At 88ms/org, 1,000 orgs complete in ~90 seconds
### 4. Monitoring
Track per-org provisioning and migration times. If the median provisioning time rises above 500ms/org, investigate PostgreSQL catalog pressure:
- `pg_stat_user_tables.n_dead_tup` for autovacuum health
- `pg_stat_bgwriter.buffers_backend` for buffer pressure
- `documentdb_api_catalog.collections` count for total table count