Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and Advanced Tasks 1-15. The design assumes 19 Stable + 12 Advanced Gradle projects under modules/mongodb*. This repository's fail-closed registry declares exactly 19 leaf identities, so those modules become package boundaries inside the registered leaf :adapter:outbound:persistence-mongo, with the design's module dependency table enforced by ten ArchUnit rules. The mapping and every deviation are recorded in docs/mongodb/repository-adaptation.md. Contract highlights, all enforced by tests rather than convention: - Transaction body retry and commit retry are separate loops. A new session per body attempt; commit-only retry on an unknown commit. The body is never replayed after a commit ambiguity, so a failover cannot become a duplicate. - MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and failure, and MongoFailureContext records only the design-permitted fields. - Failure classification reads server error labels before numeric codes. - BSON representations come from a pinned manifest, never a library default, and a golden type-signature gate fails on any drift. - Index and validator changes go through the manifest and the admin plane; metadata ownership gates every drop. - Every Advanced capability refuses construction unless its flag is enabled. Verified against real servers, not only unit tests. Running the lanes for the first time exposed four defects that a green `check` had hidden: - Four release lanes passed while executing zero tests; the gate now counts executed tests per lane and fails on zero. - The "single replica set" fixture was a standalone, because Testcontainers 2.x needs withReplicaSet(); its test only asserted a connection string. - The three-node fixture was three independent clusters, so no election could occur, and awaitNewPrimary() compared against the post-stop primary. - The migration lease checked modifiedCount, so a same-millisecond refresh read as a lost lease. scripts/verify-mongodb-platform.sh now reports: 9 lanes, 0 skipped, 0 failed, every evidence category produced. scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology evidence (real sharded cluster, real KMS, real target deployment) is unobtainable here, so it is named rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
5.0 KiB
Markdown
91 lines
5.0 KiB
Markdown
# Advanced — Multi-tenancy
|
|
|
|
**Capabilities:** `MongoCapability.SHARED_COLLECTION_TENANCY` (Advanced),
|
|
`MongoCapability.DATABASE_PER_TENANT` (Experimental)
|
|
**Properties:** `ca-skeleton.persistence-mongo.advanced.shared-collection-tenancy.enabled`,
|
|
`ca-skeleton.persistence-mongo.advanced.database-per-tenant.enabled`
|
|
|
|
## 1. Shared collection
|
|
|
|
Every tenant's documents live in one collection, discriminated by a tenant field.
|
|
|
|
`MongoTenantContext` carries the tenant. `MongoTenantPredicateInjector` adds the tenant predicate to
|
|
every query, every atomic filter and the **first** aggregation stage. `TenantScopedMongoOperations`
|
|
is the entry point, so a caller cannot construct an unscoped operation by forgetting.
|
|
|
|
Three details are load-bearing:
|
|
|
|
- **Injection, not convention.** A tenant predicate that each query is expected to add itself is a
|
|
cross-tenant leak waiting for one missed `where(...)`. The injector adds it structurally.
|
|
- **First aggregation stage.** `firstStageMatch(...)` places the tenant `$match` before anything else.
|
|
A `$lookup` or `$group` that runs before the tenant filter has already crossed the boundary, even if
|
|
a later stage filters the output.
|
|
- **An absent tenant is not "all tenants".** The injector takes an `Optional<MongoTenantContext>` so
|
|
the missing case is a decision the policy makes explicitly, not a predicate that quietly disappears.
|
|
|
|
`MongoTenantManifestValidator.validate(manifest, tenantScopedUniqueIndexes)` checks that every unique
|
|
index that should be per-tenant actually includes the tenant field. A unique index on `email` alone in
|
|
a shared collection makes an email globally unique across tenants — tenant B cannot register an
|
|
address tenant A already used, which is both a bug and an information leak.
|
|
|
|
`requireShardKeyAnalysed(...)` requires a shard-key readiness report before a shared-collection tenant
|
|
model is sharded: tenant id as a shard key prefix concentrates the largest tenant on one shard.
|
|
|
|
### Observability
|
|
|
|
`tenantId` and `rawTenantId` are on `MongoObservationConvention`'s forbidden tag list. Cardinality
|
|
grows with the customer list, and the tag ships tenant identity into the metrics backend.
|
|
|
|
## 2. Database per tenant (Experimental)
|
|
|
|
`MongoTenantDatabaseResolver` maps a tenant to its database; `MongoTenantClientRegistry` holds the
|
|
clients.
|
|
|
|
Experimental for a specific reason: it is correct in the small and unbounded in the large. Each tenant
|
|
database costs connections, file handles and monitoring cardinality. It works beautifully at 20
|
|
tenants and falls over at 2,000, and nothing in a functional test distinguishes the two. Promotion
|
|
requires operational scale evidence.
|
|
|
|
`MongoTenantLifecyclePolicy`:
|
|
|
|
- `requireActivationReady(tenantKey, schemaAndIndexesValidated)` — a tenant is not activated until its
|
|
schema and indexes are validated. Activating first means the first customer request is the migration
|
|
test.
|
|
- `requireDeleteAllowed(...)` — deletion requires an explicit retention decision. Dropping a tenant
|
|
database is irreversible and takes the backup surface with it.
|
|
|
|
`MongoTenantMigrationCoordinator` runs a migration across tenant databases with per-tenant results.
|
|
Partial failure is normal and must be reported per tenant: "migration failed" across 500 databases is
|
|
not a report anyone can act on.
|
|
|
|
## 3. Choosing
|
|
|
|
| | Shared collection | Database per tenant |
|
|
|---|---|---|
|
|
| Isolation | Logical, enforced by injection | Physical |
|
|
| Tenant count | Unbounded | Bounded by connections and file handles |
|
|
| Per-tenant restore | Hard | Natural |
|
|
| Noisy neighbour | Shared resources | Isolated |
|
|
| Migration | One collection | N databases, partial failures |
|
|
| Cross-tenant query | Possible (and must be forbidden) | Structurally impossible |
|
|
|
|
Shared collection is the default. Database-per-tenant is for a small number of tenants with a
|
|
contractual isolation or per-tenant-restore requirement.
|
|
|
|
## 4. Failure recovery
|
|
|
|
| Symptom | Cause | Action |
|
|
|---|---|---|
|
|
| Cross-tenant data visible | An operation bypassed `TenantScopedMongoOperations` | Treat as a security incident. Find the path, close it, audit access. |
|
|
| Unique constraint fires across tenants | Unique index missing the tenant field | Rebuild the index with the tenant field as prefix; the validator catches this before it ships. |
|
|
| One shard holds most data | Tenant id as shard-key prefix with a dominant tenant | Refine the shard key with a high-cardinality suffix. |
|
|
| Connection exhaustion | Database-per-tenant beyond the connection budget | The scale limit. Consolidate or move to shared collections. |
|
|
| Migration partially applied across tenants | Normal | `MongoTenantMigrationCoordinator` reports per tenant; re-run for the failures only. |
|
|
|
|
## 5. Promotion evidence
|
|
|
|
Shared-collection tenancy: actual-topology evidence, a security review covering cross-tenant access,
|
|
a migration path, failure cases (injection proven on query, atomic filter and first aggregation
|
|
stage), this runbook. Database-per-tenant additionally requires **operational scale evidence** and
|
|
stays Experimental until it exists.
|