Section 13: Scenario Based Question

🎭 MongoDB Interview Scenarios

Real-World Problem-Solving Questions

🎯 About Scenario-Based Questions

Scenario-based questions test your ability to apply MongoDB knowledge to real-world problems. These questions assess problem-solving, architectural thinking, and practical experience.

💡 How to Approach Scenarios:
  • Clarify Requirements: Ask questions about scale, constraints, priorities
  • Identify Root Cause: Don't jump to solutions immediately
  • Consider Trade-offs: Discuss pros and cons of each approach
  • Think Holistically: Consider performance, maintainability, cost
  • Provide Multiple Options: Show you can think flexibly
20
Real-World Scenarios
Senior+
Difficulty Level
FAANG
Companies
⚠️ Common Mistakes:
  • Jumping to solutions without understanding the problem
  • Ignoring trade-offs and constraints
  • Over-engineering simple problems
  • Not asking clarifying questions
  • Focusing only on one aspect (e.g., only performance)

⚡ Performance Issues

Scenarios involving slow queries and optimization challenges.

Scenario #1 Medium Amazon
📋 Problem:

Your e-commerce application has a products collection with 10 million documents. Users are complaining that search by product name is extremely slow (taking 5-10 seconds). The query being used is: db.products.find({name: /laptop/i})

Current State:

  • Collection size: 10M documents
  • Average document size: 2KB
  • No indexes except _id
  • Users search for products by name frequently
Your Task:
  • Identify why the query is slow
  • Propose solutions to fix the performance issue
  • Discuss trade-offs of each solution
  • Recommend the best approach and why

✓ Complete Solution:

Step 1: Diagnose the Problem
// Check query execution
db.products.find({name: /laptop/i}).explain("executionStats")

// Results show:
// - COLLSCAN (collection scan)
// - totalDocsExamined: 10,000,000
// - nReturned: 150
// - executionTimeMillis: 8500

Problem: Full collection scan of 10M documents!

The regex query without index forces MongoDB to examine every single document.

Step 2: Solution Options

Option 1: Create Text Index (RECOMMENDED)

// Create text index
db.products.createIndex({ name: "text", description: "text" })

// Update query to use text search
db.products.find({ $text: { $search: "laptop" } })

// Results:
// - IXSCAN (index scan)
// - executionTimeMillis: 45ms
// - 200x faster!

Pros:
✓ Purpose-built for text search
✓ Case-insensitive by default
✓ Supports stemming ("laptop" matches "laptops")
✓ Can search multiple fields
✓ Relevance scoring available

Cons:
✗ Exact phrase matching requires quotes
✗ Only one text index per collection
✗ Slightly more disk space

Option 2: Regular Index with Case-Insensitive Collation

// Create regular index
db.products.createIndex({ name: 1 })

// Use with collation for case-insensitive
db.products.find({ name: "laptop" })
  .collation({ locale: "en", strength: 2 })

Pros:
✓ Works for exact match and prefix
✓ Simpler than text index

Cons:
✗ Regex still slow with index
✗ Doesn't support full-text features
✗ Must use collation for case-insensitive

Option 3: Application-Level Search (Elasticsearch)

// Sync MongoDB to Elasticsearch
// Use Elasticsearch for search queries
// MongoDB for transactional data

Pros:
✓ Most powerful search capabilities
✓ Advanced features (autocomplete, fuzzy, analytics)
✓ Scales independently

Cons:
✗ Additional infrastructure
✗ Complexity of maintaining sync
✗ Higher cost
Step 3: Recommendation

Use Text Index because:

  • Solves the immediate problem (200x improvement)
  • No additional infrastructure needed
  • Native MongoDB feature
  • Good enough for most e-commerce search

Future: If search requirements become more complex (autocomplete, typo tolerance, advanced ranking), consider Elasticsearch.

✓ Implementation Plan:
  1. Create text index during off-peak hours
  2. Test query performance in staging
  3. Update application code to use $text operator
  4. Monitor index size and query performance
  5. Add product descriptions to text index for richer search
Scenario #2 Hard Google
📋 Problem:

Your social media app's newsfeed query is slow. Users see posts from people they follow, sorted by timestamp. The query takes 2-3 seconds for users following 1000+ people.

// Current query
db.posts.find({
  userId: { $in: [list of 1000 userIds] }
}).sort({ timestamp: -1 }).limit(20)

Collection Stats:

  • 100M posts total
  • Average 500 posts per user
  • Index on {userId: 1, timestamp: -1}

✓ Complete Solution:

Problem Analysis

The $in operator with 1000 values forces MongoDB to:

  • Perform 1000 separate index scans
  • Merge and sort results in memory
  • Very inefficient at scale
Solution: Fan-out on Write (Feed Generation)
// Create a newsfeed collection
db.newsfeeds.insertOne({
  _id: userId,
  posts: [
    { postId: "p1", userId: "u1", timestamp: ISODate(...), content: "..." },
    { postId: "p2", userId: "u5", timestamp: ISODate(...), content: "..." },
    // ... latest 100 posts
  ]
})

// When user creates post, push to all followers' feeds
db.newsfeeds.updateMany(
  { _id: { $in: followerIds } },
  { 
    $push: { 
      posts: {
        $each: [newPost],
        $position: 0,
        $slice: 100  // Keep only latest 100
      }
    }
  }
)

// Reading newsfeed becomes instant
db.newsfeeds.findOne({ _id: currentUserId })

// Performance:
// - Read: O(1) - single document lookup
// - Write: O(followers) - but asynchronous
// - Query time: ~5ms vs 2000ms!

Pros:

  • ✓ Extremely fast reads (critical for UX)
  • ✓ Scales to any follower count
  • ✓ Pre-computed, ready to serve

Cons:

  • ✗ More write operations
  • ✗ Storage duplication
  • ✗ Eventual consistency (slight delay in feeds)
Alternative: Hybrid Approach
// For users with many followers (celebrities):
// - Don't fan-out to all followers
// - Followers query celebrity posts directly
// - Cache results

// For regular users:
// - Use fan-out approach

function generateFeed(userId) {
  const user = db.users.findOne({ _id: userId });
  
  if (user.followerCount > 10000) {
    // Celebrity - followers query directly
    return queryCelebrityPosts(user.following);
  } else {
    // Regular user - use pre-generated feed
    return db.newsfeeds.findOne({ _id: userId });
  }
}
✓ Recommended Approach:

Use fan-out on write with hybrid optimization for celebrities. This matches how Twitter, Instagram, and Facebook handle newsfeeds.

📐 Schema Design Scenarios

Real-world data modeling challenges.

Scenario #3 Hard Meta
📋 Problem:

You're designing a schema for a blog platform. Each blog post can have thousands of comments, and comments can have replies (nested comments). How would you design this schema?

Requirements:

  • Display post with latest 50 comments
  • Paginate through all comments
  • Show replies nested under comments
  • Viral posts can have 100,000+ comments
  • Real-time comment updates

✓ Complete Solution:

❌ Bad Approach: Embed All Comments
// DON'T DO THIS
{
  _id: "post1",
  title: "My Blog Post",
  comments: [
    { text: "Great!", user: "Alice", replies: [...] },
    { text: "Nice!", user: "Bob", replies: [...] },
    // ... 100,000 comments
  ]
}

Problems:
✗ Exceeds 16MB document limit
✗ Cannot paginate comments efficiently
✗ Must load entire document to read post
✗ Slow to add new comments (updates whole doc)
✓ Good Approach: Hybrid Model
// Posts collection
{
  _id: "post1",
  title: "My Blog Post",
  content: "...",
  commentCount: 1523,
  // Embed ONLY latest 50 comments for quick display
  recentComments: [
    {
      _id: "c1",
      text: "Great post!",
      user: "Alice",
      timestamp: ISODate(...),
      replyCount: 3
    },
    // ... latest 50
  ]
}

// Comments collection (for ALL comments)
{
  _id: "c1",
  postId: "post1",
  text: "Great post!",
  user: "Alice",
  timestamp: ISODate(...),
  parentId: null,  // null for top-level, commentId for replies
  depth: 0         // 0 for top-level, 1 for first reply, etc.
}

// Create indexes
db.comments.createIndex({ postId: 1, timestamp: -1 })
db.comments.createIndex({ parentId: 1, timestamp: 1 })

Query Patterns:

// 1. Display post with recent comments (fast!)
db.posts.findOne({ _id: "post1" })
// Recent comments already embedded, no extra query needed

// 2. Paginate through all comments
db.comments.find({ postId: "post1", parentId: null })
  .sort({ timestamp: -1 })
  .skip(50)
  .limit(50)

// 3. Load replies for a comment
db.comments.find({ parentId: "c1" })
  .sort({ timestamp: 1 })

// 4. Add new comment
db.comments.insertOne({
  postId: "post1",
  text: "New comment",
  // ...
})

// Update post's recent comments (can be async)
db.posts.updateOne(
  { _id: "post1" },
  {
    $push: {
      recentComments: {
        $each: [newComment],
        $position: 0,
        $slice: 50
      }
    },
    $inc: { commentCount: 1 }
  }
)
Benefits of This Approach
  • ✓ Post page loads instantly (recent comments embedded)
  • ✓ No document size limits (comments in separate collection)
  • ✓ Efficient pagination
  • ✓ Easy to implement nested replies
  • ✓ Can update comments independently
⚠️ Handling Deep Nesting:

Limit reply depth to 3-4 levels. Reddit and HN use this approach. Beyond that, use "show more" that loads flattened view.

📈 Scaling Challenges

Scenarios about handling growth and scale.

Scenario #4 Hard Netflix
📋 Problem:

Your application has grown from 1M to 100M users. The MongoDB server is hitting CPU limits, write latency is increasing, and the dataset is 5TB. What's your scaling strategy?

Current Setup:

  • Single replica set (1 primary + 2 secondaries)
  • 5TB total data
  • 10K writes/second at peak
  • 50K reads/second
  • 32GB RAM per server (working set: 100GB)

✓ Complete Solution:

Problem Analysis
  • Working set (100GB) > RAM (32GB) → disk I/O bottleneck
  • Single primary can't handle 10K writes/sec
  • Need to distribute data AND write load
Solution: Implement Sharding

Step 1: Choose Shard Key

// Assuming users collection
// Option 1: Hashed userId (RECOMMENDED)
sh.shardCollection("app.users", { userId: "hashed" })

Why hashed userId:
✓ Even distribution (no hotspots)
✓ High cardinality (100M unique values)
✓ Queries by userId are targeted

// Option 2: Compound key (if geographic distribution matters)
sh.shardCollection("app.users", { region: 1, userId: 1 })

Why NOT sequential _id:
✗ All new writes go to one shard (hotspot)
✗ Uneven distribution

Step 2: Initial Shard Configuration

// Start with 4 shards (each is a replica set)
// 
// Shard 1: 1.25TB data, handles userId hash 0-25%
// Shard 2: 1.25TB data, handles userId hash 25-50%
// Shard 3: 1.25TB data, handles userId hash 50-75%
// Shard 4: 1.25TB data, handles userId hash 75-100%

// Each shard:
// - 1 Primary + 2 Secondaries
// - 64GB RAM per server (working set: 25GB - fits in RAM!)
// - Handles 2.5K writes/sec (manageable)

// Config Servers: 3-node replica set for metadata
// Mongos: 4+ query routers (stateless, can scale independently)
Step 3: Migration Strategy
// Phase 1: Setup (Week 1)
1. Provision 4 shard replica sets
2. Setup config servers
3. Deploy mongos routers
4. Test in staging

// Phase 2: Enable Sharding (Week 2)
1. Enable sharding on database
2. Shard the collection
3. MongoDB starts splitting and balancing chunks
4. Monitor balancer progress

// Phase 3: Cutover (Week 3)
1. Update application to connect to mongos (not replica set)
2. Monitor performance
3. Gradual traffic migration
4. Rollback plan ready

// Phase 4: Optimization (Week 4+)
1. Tune chunk size if needed
2. Add more shards if necessary
3. Optimize queries to include shard key
✓ Expected Results:
  • Write capacity: 10K/sec distributed across 4 shards = 2.5K/shard (comfortable)
  • Read capacity: 50K/sec distributed across replicas
  • Working set per shard: 25GB fits in 64GB RAM
  • Room to grow: Can add more shards as needed
⚠️ Important Considerations:
  • Shard key is immutable - choose carefully!
  • Queries without shard key become broadcast queries (slow)
  • Monitor shard balance regularly
  • Have runbook for shard failures

🔒 Data Integrity Scenarios

Scenarios involving transactions, consistency, and data correctness.

Scenario #5 Hard Amazon
📋 Problem:

You're building a banking application where users can transfer money between accounts. You need to ensure that money transfers are atomic - either both the debit and credit happen, or neither does.

// Current non-atomic approach (WRONG!)
db.accounts.updateOne(
  { _id: "account1" },
  { $inc: { balance: -100 } }
)

db.accounts.updateOne(
  { _id: "account2" },
  { $inc: { balance: 100 } }
)

// Problem: If server crashes between operations, 
// money disappears or duplicates!

Requirements:

  • Atomicity: Both operations succeed or both fail
  • Prevent negative balances
  • Handle concurrent transfers
  • Audit trail of all transactions

✓ Complete Solution:

Solution: Use MongoDB Transactions
const session = client.startSession();

try {
  session.startTransaction({
    readConcern: { level: 'snapshot' },
    writeConcern: { w: 'majority' }
  });

  // Step 1: Check source account balance
  const sourceAccount = await db.accounts.findOne(
    { _id: "account1" },
    { session }
  );

  if (sourceAccount.balance < 100) {
    throw new Error("Insufficient funds");
  }

  // Step 2: Debit source account
  await db.accounts.updateOne(
    { _id: "account1", balance: { $gte: 100 } },
    { $inc: { balance: -100 } },
    { session }
  );

  // Step 3: Credit destination account
  await db.accounts.updateOne(
    { _id: "account2" },
    { $inc: { balance: 100 } },
    { session }
  );

  // Step 4: Record transaction for audit
  await db.transactions.insertOne({
    from: "account1",
    to: "account2",
    amount: 100,
    timestamp: new Date(),
    status: "completed"
  }, { session });

  // Commit transaction
  await session.commitTransaction();
  
  console.log("Transfer successful");

} catch (error) {
  // Rollback on any error
  await session.abortTransaction();
  console.error("Transfer failed:", error);
  throw error;
  
} finally {
  session.endSession();
}
Key Points
  • Snapshot isolation: Reads see consistent data throughout transaction
  • w:majority: Ensures durability (replicated to majority)
  • Balance check in update: Prevents race conditions
  • Audit trail: Transaction record for compliance
⚠️ Important Considerations:
  • Transactions require replica set (not standalone)
  • Distributed transactions (sharded) available since 4.2
  • Performance impact: ~30% slower than non-transactional
  • 60 second timeout by default
  • Use only when necessary (banking, payments, inventory)
Alternative: Two-Phase Commit (Manual)

Before MongoDB 4.0 (no transactions), developers used manual 2PC:

// Step 1: Create pending transaction
db.transactions.insertOne({
  _id: "txn1",
  from: "account1",
  to: "account2",
  amount: 100,
  state: "pending"
})

// Step 2: Apply operations with transaction reference
db.accounts.updateOne(
  { _id: "account1" },
  { $inc: { balance: -100, pendingDebits: 100 } }
)

// Step 3: If successful, commit
db.transactions.updateOne(
  { _id: "txn1" },
  { $set: { state: "committed" } }
)

// Step 4: Finalize accounts
db.accounts.updateOne(
  { _id: "account1" },
  { $inc: { pendingDebits: -100 } }
)

// Complex and error-prone! Use native transactions instead.
✓ Best Practice:

Use native MongoDB transactions (4.0+) for multi-document ACID operations. Simpler, safer, and supported by MongoDB.

Scenario #6 Medium Uber
📋 Problem:

Your ride-sharing app allows users to cancel rides. However, you're seeing duplicate refunds - users getting refunded twice for the same cancellation. The cancellation handler is being called multiple times due to network retries.

// Current code (has race condition)
async function cancelRide(rideId, userId) {
  const ride = await db.rides.findOne({ _id: rideId });
  
  if (ride.status === 'active') {
    await processRefund(userId, ride.fare);
    await db.rides.updateOne(
      { _id: rideId },
      { $set: { status: 'cancelled' } }
    );
  }
}

// Problem: Two simultaneous calls can both pass the check!

✓ Complete Solution:

Solution 1: Atomic Update with findAndModify
async function cancelRide(rideId, userId) {
  // Atomic: check status and update in single operation
  const result = await db.rides.findOneAndUpdate(
    { 
      _id: rideId, 
      status: 'active'  // Only update if still active
    },
    { 
      $set: { 
        status: 'cancelled',
        cancelledAt: new Date(),
        cancelledBy: userId
      } 
    },
    { 
      returnDocument: 'after' 
    }
  );

  // If result is null, ride was already cancelled
  if (!result.value) {
    console.log("Ride already cancelled");
    return { success: false, reason: "already_cancelled" };
  }

  // Process refund only if update succeeded
  await processRefund(userId, result.value.fare);
  
  return { success: true };
}

// Guarantees: Only ONE call can change status from active to cancelled
// All other concurrent calls will get null result
Solution 2: Idempotency Key
// Add idempotency key to ride cancellations
async function cancelRide(rideId, userId, idempotencyKey) {
  // Check if we've already processed this request
  const existing = await db.cancellations.findOne({
    idempotencyKey: idempotencyKey
  });

  if (existing) {
    // Already processed, return previous result
    return existing.result;
  }

  // Process cancellation
  const result = await db.rides.findOneAndUpdate(
    { _id: rideId, status: 'active' },
    { $set: { status: 'cancelled' } }
  );

  if (result.value) {
    await processRefund(userId, result.value.fare);
  }

  // Store result with idempotency key
  await db.cancellations.insertOne({
    idempotencyKey: idempotencyKey,
    rideId: rideId,
    processedAt: new Date(),
    result: { success: true }
  });

  return { success: true };
}

// Client includes idempotency key with request
// cancelRide('ride123', 'user456', 'cancel-uuid-1234')

Used by: Stripe, PayPal, AWS - industry standard for payment APIs

✓ Recommendation:

Use both approaches:

  • Atomic update prevents race conditions
  • Idempotency key handles retries and duplicate requests
  • Defense in depth for financial operations
Scenario #7 Medium Netflix
📋 Problem:

You have a primary-secondary replica set. You notice that sometimes users see stale data - they update their profile, but immediately reading shows the old data. This happens during high load.

✓ Complete Solution:

Root Cause: Reading from Secondaries
// Application is using secondary reads
const client = new MongoClient(uri, {
  readPreference: 'secondary'  // This causes stale reads!
});

Timeline:
t=0: User updates profile (write goes to PRIMARY)
t=1: User reads profile (read goes to SECONDARY - not yet replicated)
t=1: User sees OLD data!
t=2: Secondary catches up (replication lag)

Replication lag during high load: 100-500ms typical
Solution Options

Option 1: Read from Primary

// Change read preference to primary
const client = new MongoClient(uri, {
  readPreference: 'primary'
});

// Or per-query
db.users.findOne(
  { _id: userId },
  { readPreference: 'primary' }
);

Pros:
✓ Always consistent - no stale reads
✓ Simple solution

Cons:
✗ All read load on primary
✗ Doesn't scale reads

Option 2: Read Your Own Writes Pattern

// After write, read from primary for short time
async function updateProfile(userId, updates) {
  await db.users.updateOne(
    { _id: userId },
    { $set: updates },
    { writeConcern: { w: 'majority' } }
  );

  // Set flag: "just wrote, read from primary"
  await redis.setex(`user:${userId}:just-wrote`, 2, '1');
}

async function getProfile(userId) {
  const justWrote = await redis.get(`user:${userId}:just-wrote`);
  
  const readPref = justWrote ? 'primary' : 'secondary';
  
  return db.users.findOne(
    { _id: userId },
    { readPreference: readPref }
  );
}

// User sees their own writes, others can read from secondary

Option 3: Read Concern "majority"

// Read only data acknowledged by majority
db.users.findOne(
  { _id: userId },
  { 
    readConcern: { level: 'majority' },
    readPreference: 'secondary'
  }
);

// Ensures you don't read data that might be rolled back
✓ Recommended Approach:
  • Critical data: readPreference: 'primary'
  • User's own data: Read-your-own-writes pattern
  • Analytics/reports: readPreference: 'secondary'
  • Financial data: readConcern: 'majority'

🔄 Migration & Operations Scenarios

Real-world operational challenges and migration strategies.

Scenario #8 Hard Google
📋 Problem:

You need to migrate 500GB of production data from PostgreSQL to MongoDB with zero downtime. The application must continue serving traffic during the migration.

Constraints:

  • Zero downtime requirement
  • Data consistency required
  • 500GB data (~100M records)
  • Application serves 10K requests/sec
  • Must validate data integrity

✓ Complete Solution:

Migration Strategy: Dual-Write Pattern

Phase 1: Initial Bulk Load (Week 1-2)

// 1. Take snapshot of PostgreSQL
pg_dump production_db > snapshot.sql

// 2. Write migration script
const batchSize = 10000;
let offset = 0;

while (true) {
  // Read batch from PostgreSQL
  const rows = await pg.query(`
    SELECT * FROM users 
    ORDER BY id 
    LIMIT ${batchSize} OFFSET ${offset}
  `);

  if (rows.length === 0) break;

  // Transform to MongoDB format
  const docs = rows.map(row => ({
    _id: row.id,
    name: row.name,
    email: row.email,
    // ... transform other fields
    migratedAt: new Date()
  }));

  // Bulk insert to MongoDB
  await mongodb.users.insertMany(docs, { ordered: false });

  offset += batchSize;
  console.log(`Migrated ${offset} records`);
}

// Run during off-peak hours
// Estimated time: 4-8 hours for 100M records

Phase 2: Enable Dual-Write (Week 3)

// Update application to write to BOTH databases
class UserRepository {
  async create(userData) {
    // Write to PostgreSQL (primary source of truth)
    const pgResult = await pg.query(
      'INSERT INTO users (...) VALUES (...)',
      userData
    );

    // Async write to MongoDB (fire-and-forget)
    this.writeToMongoDB(userData).catch(err => {
      // Log error but don't fail request
      logger.error('MongoDB write failed', err);
      // Queue for retry
      retryQueue.add(userData);
    });

    return pgResult;
  }

  async writeToMongoDB(userData) {
    return mongodb.users.insertOne(userData);
  }
}

// All writes go to both systems
// PostgreSQL is still primary (reads come from PG)

Phase 3: Catch-up (Week 3)

// Compare data and sync differences
async function catchUpSync() {
  // Get all IDs from PostgreSQL
  const pgIds = await pg.query('SELECT id FROM users');

  for (const {id} of pgIds) {
    // Check if exists in MongoDB
    const mongoDoc = await mongodb.users.findOne({ _id: id });

    if (!mongoDoc) {
      // Missing in MongoDB - copy over
      const pgRow = await pg.query(
        'SELECT * FROM users WHERE id = $1', 
        [id]
      );
      await mongodb.users.insertOne(transformRow(pgRow));
    } else {
      // Exists - verify data matches
      const pgRow = await pg.query(
        'SELECT * FROM users WHERE id = $1', 
        [id]
      );
      if (!dataMatches(pgRow, mongoDoc)) {
        // Data mismatch - overwrite with PG data
        await mongodb.users.replaceOne(
          { _id: id },
          transformRow(pgRow)
        );
      }
    }
  }
}

// Run multiple times until <0.1% mismatches

Phase 4: Validation (Week 4)

// Automated validation checks
async function validateMigration() {
  // 1. Count check
  const pgCount = await pg.query('SELECT COUNT(*) FROM users');
  const mongoCount = await mongodb.users.countDocuments();
  assert(pgCount === mongoCount, 'Count mismatch');

  // 2. Sample verification (check 10,000 random records)
  const samples = await pg.query(`
    SELECT * FROM users 
    ORDER BY RANDOM() 
    LIMIT 10000
  `);

  for (const row of samples) {
    const mongoDoc = await mongodb.users.findOne({ _id: row.id });
    assert(dataMatches(row, mongoDoc), `Mismatch for ID ${row.id}`);
  }

  // 3. Checksum verification
  // Compare aggregate checksums of data
}

// Run validation daily until confident

Phase 5: Switch Reads to MongoDB (Week 5)

// Gradual read migration with feature flag
class UserRepository {
  async findById(id) {
    const readFromMongo = await featureFlag.isEnabled(
      'read_from_mongodb',
      id  // Can enable per-user or percentage
    );

    if (readFromMongo) {
      return mongodb.users.findOne({ _id: id });
    } else {
      return pg.query('SELECT * FROM users WHERE id = $1', [id]);
    }
  }
}

// Day 1: 1% of traffic to MongoDB
// Day 2: 5% of traffic
// Day 3: 10% of traffic
// ...
// Day 7: 100% of traffic

// Monitor error rates, latency, data consistency

Phase 6: Switch Writes to MongoDB (Week 6)

// After reads are stable, switch writes
class UserRepository {
  async create(userData) {
    // Write to MongoDB (now primary)
    const mongoResult = await mongodb.users.insertOne(userData);

    // Keep dual-write to PG for safety (can be async)
    this.writeToPG(userData).catch(err => {
      logger.error('PG write failed', err);
    });

    return mongoResult;
  }
}

// Monitor for 1-2 weeks

Phase 7: Decommission PostgreSQL (Week 8)

// After MongoDB is stable for 2+ weeks:
// 1. Stop dual-writes to PostgreSQL
// 2. Keep PostgreSQL as cold backup for 30 days
// 3. Decommission PostgreSQL database
// 4. Celebrate! 🎉
⚠️ Risk Mitigation:
  • Rollback plan: Can switch back to PG at any phase
  • Data integrity: PG remains source of truth until Phase 6
  • Monitoring: Track error rates, latency, data consistency
  • Gradual rollout: Feature flags allow percentage-based migration
✓ Timeline Summary:
  • Week 1-2: Initial bulk load
  • Week 3: Enable dual-write, catch-up sync
  • Week 4: Validation
  • Week 5: Gradual read migration
  • Week 6: Switch writes to MongoDB
  • Week 7-8: Monitor and decommission

Total: 8 weeks with zero downtime

Scenario #9 Medium Meta
📋 Problem:

Your MongoDB primary node went down at 2 AM. The automatic failover didn't work properly, and your application is down. What do you do?

Situation:

  • Primary crashed (hardware failure)
  • 2 secondaries are healthy
  • No new primary elected
  • Application showing "no primary" errors
  • On-call at 2 AM, pressure to restore service

✓ Complete Solution:

Step 1: Assess the Situation (5 minutes)
// Connect to one of the healthy secondaries
mongo mongodb://secondary1:27017

// Check replica set status
rs.status()

// Look for:
// - Which nodes are UP/DOWN
// - Last heartbeat times
// - Current state of each member
// - Any error messages

Expected issue:
- Primary shows as "not reachable"
- Secondaries show as "SECONDARY"
- No "PRIMARY" in the set
Step 2: Understand Why Election Failed

Common reasons:

  • Network partition: Secondaries can't reach each other (no majority)
  • Priority 0: Secondaries have priority:0 (can't become primary)
  • Votes issue: Not enough voting members online
  • Arbiter down: In 2-secondary + arbiter setup, arbiter needed for majority
// Check replica set configuration
rs.conf()

// Look for:
cfg.members.forEach(m => {
  console.log(`${m.host}: priority=${m.priority}, votes=${m.votes}`);
});

// Check if majority is possible
// Need: (total_voting_members / 2) + 1 members online
Step 3: Force Manual Election (EMERGENCY ONLY)
// Connect to best secondary (most up-to-date)
mongo mongodb://secondary1:27017

// Check which secondary has latest data
rs.status().members.forEach(m => {
  if (m.state === 2) {  // SECONDARY
    console.log(`${m.name}: optime=${m.optimeDate}`);
  }
});

// Force the most up-to-date secondary to become primary
rs.stepDown(0)  // If there's still a primary somehow

// Method 1: Reconfigure with only healthy members
cfg = rs.conf()
cfg.members = cfg.members.filter(m => 
  m.host !== "failed-primary:27017"
)
cfg.version++
rs.reconfig(cfg, {force: true})

// Method 2: If that doesn't work, reconfigure to single member
cfg = rs.conf()
cfg.members = [
  {
    _id: 0,
    host: "secondary1:27017",
    priority: 1,
    votes: 1
  }
]
cfg.version++
rs.reconfig(cfg, {force: true})

// One secondary should become primary now
rs.status()  // Verify PRIMARY exists
Step 4: Restore Service (10 minutes)
// 1. Verify new primary is accepting writes
db.test.insertOne({ test: "write", timestamp: new Date() })

// 2. Check application can connect
// Update connection string if needed to point to new primary

// 3. Monitor application recovery
// Check logs, metrics, error rates

// 4. Communicate status
// Update incident channel: "Primary restored, monitoring"
Step 5: Add Back Members (Next Day)
// After service is restored and stable:

// 1. Fix or replace failed primary hardware

// 2. Add healthy members back to replica set
cfg = rs.conf()
cfg.members.push({
  _id: 1,
  host: "secondary2:27017",
  priority: 1,
  votes: 1
})
cfg.version++
rs.reconfig(cfg)

// 3. Add repaired primary back (as secondary)
cfg = rs.conf()
cfg.members.push({
  _id: 2,
  host: "repaired-node:27017",
  priority: 1,
  votes: 1
})
cfg.version++
rs.reconfig(cfg)

// 4. Wait for initial sync to complete
rs.status()  // Check stateStr: "SECONDARY" not "RECOVERING"

// 5. Restore original configuration if needed
⚠️ Critical Warnings:
  • force: true is dangerous: Can cause data loss if used incorrectly
  • Only use in emergency: When automatic failover failed
  • Choose most up-to-date secondary: Check optimeDate
  • Document everything: Write postmortem on why failover failed
✓ Prevention for Future:
  • Use odd number of voting members (3, 5, 7)
  • Ensure proper network connectivity between members
  • Set appropriate priorities
  • Monitor replica set health 24/7
  • Have runbooks for common failures
  • Practice failover drills during maintenance windows
Scenario #10 Hard Amazon
📋 Problem:

You accidentally ran a delete query that deleted 1 million customer records. The delete happened 30 minutes ago. How do you recover the data?

Setup:

  • Replica set with 3 members
  • Automated daily backups (last backup: 24 hours ago)
  • Oplog window: 48 hours
  • Delete happened 30 minutes ago
  • System still running, processing new data

✓ Complete Solution:

Option 1: Point-in-Time Recovery from Oplog (BEST)
// Step 1: STOP writes immediately
// Prevent more data changes during recovery
db.fsyncLock()  // On primary

// Step 2: Find the problematic delete operation
db.local.oplog.rs.find({
  op: "d",  // delete operation
  ns: "mydb.customers",
  ts: { 
    $gte: Timestamp(deleteTime - 600, 0),  // 10 min before
    $lte: Timestamp(deleteTime + 600, 0)   // 10 min after
  }
}).pretty()

// Identify exact timestamp of bad delete
const badDeleteTimestamp = Timestamp(1702345678, 1);

// Step 3: Take backup of current state (safety)
mongodump --host localhost --port 27017 --out /backup/current

// Step 4: Create restore environment
// Option A: Use hidden secondary for recovery
cfg = rs.conf()
cfg.members[2].priority = 0
cfg.members[2].hidden = true
rs.reconfig(cfg)

// Step 5: Replay oplog up to bad delete
// On hidden secondary or new instance:

// Start from last backup (24h ago)
mongorestore /backup/daily

// Replay oplog up to (but NOT including) bad delete
db.local.oplog.rs.find({
  ts: { 
    $gt: Timestamp(backupTime),
    $lt: badDeleteTimestamp  // Stop before bad delete!
  }
}).forEach(op => {
  // Apply operation
  applyOp(op);
});

// Step 6: Extract recovered data
mongoexport --db=mydb --collection=customers \
  --out=recovered_customers.json

// Step 7: Import to production
mongoimport --db=mydb --collection=customers \
  --file=recovered_customers.json \
  --mode=upsert

// Step 8: Unlock and resume
db.fsyncUnlock()
Option 2: Use Delayed Secondary (If Available)
// If you have delayed secondary (e.g., 1 hour delay):

// Check if delayed secondary still has the data
// (Delete hasn't replicated yet)

// 1. Stop replication on delayed secondary
db.adminCommand({
  replSetSyncFrom: ""  // Stop syncing
})

// 2. Export data from delayed secondary
mongoexport --host delayed-secondary \
  --db=mydb --collection=customers \
  --query='{"deletedAt": {$exists: false}}' \
  --out=customers.json

// 3. Import back to primary
mongoimport --host primary \
  --db=mydb --collection=customers \
  --file=customers.json \
  --mode=upsert

// Delayed secondaries are EXACTLY for this scenario!
Option 3: If No Oplog (Worst Case)
// If oplog has cycled (delete > 48h ago):

// 1. Restore from last backup (24h ago)
mongorestore /backup/daily --db=mydb_restored

// 2. Manually identify missing records
// Compare counts, IDs, checksums

// 3. Merge with current data
// This is complex and may lose 24h of updates

// 4. Consider external sources
// - Application logs
// - Audit trails
// - External backups
// - Partner systems with data sync

// Prevention: Increase oplog size or backup frequency!
✓ Best Practice - Soft Deletes:
// Never actually delete data!
// Instead: Mark as deleted
db.customers.updateMany(
  { /* criteria */ },
  { 
    $set: { 
      deletedAt: new Date(),
      deletedBy: userId 
    } 
  }
)

// Query excludes soft-deleted
db.customers.find({ deletedAt: { $exists: false } })

// Can easily "undelete"
db.customers.updateMany(
  { deletedAt: { $exists: true } },
  { $unset: { deletedAt: "", deletedBy: "" } }
)

// Actual deletion done by batch job after 90 days
⚠️ Prevention Measures:
  • Soft deletes: Mark as deleted, don't physically delete
  • Delayed secondary: 1-hour delay gives recovery window
  • Larger oplog: 72+ hours instead of 48
  • Frequent backups: Every 6 hours instead of daily
  • Delete confirmation: Require explicit confirmation for bulk deletes
  • Audit logging: Log all delete operations
  • Permission controls: Limit who can run deletes in prod