DEV Community

Cover image for Serializable Transactions in MongoDB: The Doctor's On-Call Shift example
Franck Pachot
Franck Pachot

Posted on • Edited on

Serializable Transactions in MongoDB: The Doctor's On-Call Shift example

In this series, we explored several ways to solve the "Doctor's On-Call Shift" problem (Cahill, Röhm, Fekete (SIGMOD 2008)), which demonstrates write skew anomalies and the need for serializable transactions in SQL. Beyond using a serializable isolation level, we also addressed it with normalization, explicit parent locking, and SQL assertions. I applied document modeling in Postgres with SELECT FOR UPDATE as an alternative to a parent-child relationship, so it is natural to consider MongoDB. Since MongoDB lacks explicit locking and a serializable isolation level, we can instead use a simple update that performs atomic reads and writes in an optimistic concurrency-control style.

Here is a collection with one document per shift and a list of doctors with their on-call status for this shift:

db.shifts.insertOne({  
  _id: 1,  
  doctors: [  
    { name: "Alice", on_call: true, updated: new Date() },  
    { name: "Bob",   on_call: true, updated: new Date() }  
  ]  
});  
Enter fullscreen mode Exit fullscreen mode

Two doctors are on call for this shift. Either doctor may go off call if the other stays on call, but we must prevent any race condition that could leave the shift without a doctor.

Conditional updateOne() to avoid write skew

The following function encapsulates the business logic in a single update: for a shift with at least one other doctor on call, one doctor can be taken off on-call duty:

function goOffCall(shiftId, doctorName) {
 const res = db.shifts.updateOne(
  {
   _id: shiftId,
   // ensure at least one other doctor is on call
   doctors: {
     $elemMatch: {  name: { $ne: doctorName },  on_call: true  }
   }
  },
  {
   // set off-call the doctor identified by array filter below
   $set: {  "doctors.$[i].on_call": false,  updated: new Date()  }
  },
  {
   // identify the on-call doctor to set off-call, by its name
   arrayFilters: [  {  "i.name": doctorName,  "i.on_call": true  }  ]
  }
 );
 return res.modifiedCount > 0 ? "OFF_OK" : "OFF_FAIL";
}

Enter fullscreen mode Exit fullscreen mode

MongoDB is a document database with array operators like $elemMatch to filter array elements. Here, the condition ensures there is another doctor (name: { $ne: doctorName }) who is on call (on_call: true). In a SQL database, expressing the same rule typically requires an EXISTS subquery (semi‑join).

I could target the doctor to set off-call with "doctors.name": doctorName, which uses the positional operator $, and then update "doctors.$.on_call": false. Instead, I prefer using $[i] with an explicit array filter like "i.name": doctorName. This also lets me add "i.on_call": true, so the logic still works if doctor names are duplicated—selecting the first on-call doctor with that name to set off-call. Still, the application should ensure doctor names are unique per shift, since unique indexes cannot enforce this.

Since there is only one document per shift, if none is updated either the shift doesn’t exist, or the doctor is not in this shift, or there aren’t enough on-call doctors to let one go off call. The following calls show the return code:

test> goOffCall(1,"Alice");

OFF_OK

test> goOffCall(1,"Bob");

OFF_FAIL

Enter fullscreen mode Exit fullscreen mode

Alice was allowed to go off‑call, but Bob couldn’t, because he was the only doctor remaining on‑call.

Testing race conditions

I added a simpler function to set a doctor on call for a shift:

function goOnCall(shiftId, doctorName) {  
  const res = db.shifts.updateOne(  
    {   
      _id: shiftId,  
      "doctors" : { "$elemMatch" : { "name": doctorName, "on_call": false } } 
    },
    {   
      $set: { "doctors.$.on_call": true, updated: new Date() }  
    }  
  );  
  return res.modifiedCount > 0 ? "ON_OK" : "ON_FAIL";  
}  

Enter fullscreen mode Exit fullscreen mode

Here is Alice back to on-call again:

test> goOnCall(1,"Alice");

ON_OK

Enter fullscreen mode Exit fullscreen mode

I define an assertion function to verify the business rule for a shift by counting the doctors on call:

function checkOnCalls(shiftId) {  
  const doc = db.shifts.findOne(  
    { _id: shiftId },  
    { onCallCount: {  
        $size: { $filter: { input: "$doctors",cond: "$$this.on_call" } }
    } }  
  );  
  if (!doc) {  
    print(`❌ Shift ${shiftId} not found`);  
    return false;  
  }  
  if (doc.onCallCount < 1) {  
    print(`❌ ERROR! No doctors on call for shift ${shiftId}`);  
    return false;  
  }  

  return true;  
}  
Enter fullscreen mode Exit fullscreen mode

Now, I run a loop that randomly sets Alice or Bob on call and

const shiftId = 1;  
const doctors = ["Alice", "Bob"];  
const actions = [goOnCall, goOffCall];  

let iteration = 0;  

while (true) {  
  iteration++;  
  const doctor = doctors[Math.floor(Math.random() * doctors.length)];  
  const action = actions[Math.floor(Math.random() * actions.length)];  

  const result = action(shiftId, doctor);  
  print(`Shift ${shiftId}, Iteration ${iteration}: ${doctor} -> ${result}`);  

  if (!checkOnCalls(shiftId)) {  
    print(`🚨 Stopping: assertion broken at iteration ${iteration}`);  
    break;  // exit loop immediately  
  }   
}  

Enter fullscreen mode Exit fullscreen mode

I've run this loop in multiple sessions (you can also make it multi-threaded) and confirmed that the "Doctor's On-Call" assertion is never violated. This runs indefinitely because MongoDB guarantees data integrity—updateOne() operation is ACID, and the invariant is enforced inside a single atomic document update:

If you want to stop it and check that the assertion works, you can simply bypass the conditional update and set all doctors to off call:

db.shifts.updateOne(  
  { _id: 1 },  
  { $set: { "doctors.$[doc].on_call": false, updated: new Date() } },  
  { arrayFilters: [ { "doc.on_call": true } ] }  
);  
Enter fullscreen mode Exit fullscreen mode

The loops stop when they detect the violation:

MongoDB Schema validation

You can, and should, define schema validations on the part of your schema the application relies on, to be sure that no update bypasses the application model and logic. It is possible to add an 'at least one on‑call' rule, as well as ensuring the uniqueness of doctor's names:

db.runCommand({  
  collMod: "shifts",  
  validator: {  
    $expr: {  
      $and: [  
        // At least one doctor on call  
        {  
          $gte: [  
            {  
              $size: {                  // the number of on-call doctors
                $filter: {  
                  input: "$doctors",  
                  cond: "$$this.on_call"  
                }  
              }  
            },  
            1                          // must be at least 1
          ]  
        },  
        // Doctor names must be unique within the shift  
        {  
          $eq: [                       // $setUnion returns unique values
            { $size: "$doctors.name" },  
            { $size: { $setUnion: [ "$doctors.name" ] } }  
          ]  
        }  

      ]  
    }  
  },  
  validationLevel: "strict"            // raise error instead log continue and log violation
});  

Enter fullscreen mode Exit fullscreen mode

My manual update immediately fails:

Schema validation is a helpful safeguard, but it does not fully protect against write skew under race conditions. It runs on inserts and updates and, with validationLevel: "strict", raises an error on invalid documents—but only after MongoDB has already matched and targeted the document for update.

Key differences between conditional updates and schema validation:

Approach When Check Occurs Failure Mode
Conditional updateOne Before write, atomically with document match Returns modifiedCount: 0 (no document updated)
Schema validation After document match but before write Returns DocumentValidationFailure error

To avoid write skew, you need the correct condition in the update itself. Use schema validation as an extra safeguard for other changes, such as inserts.

Indexing documents

In PostgreSQL, using one row per shift with a JSON array of doctors makes updates atomic and eliminates race conditions but reduces indexing flexibility (for example, range scans on array fields), so the serializable isolation level or normalization to parent-child is preferable. In MongoDB, storing a one-to-many relationship in a single document is native, and full indexing remains available—for example, you can index the updated field for each doctor's on-call status:


db.shifts.createIndex({ "doctors.updated": 1 });

Enter fullscreen mode Exit fullscreen mode

This index supports equality, sorting, and range queries, such as finding shifts where the on-call status changed in the last hour:

const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);  

db.shifts.find({ "doctors.updated": { $gte: oneHourAgo } });

db.shifts.find(
 { "doctors.updated": { $gte: oneHourAgo } }
).explain("executionStats")

Enter fullscreen mode Exit fullscreen mode

Here is the execution plan:

 executionStats: {
    executionSuccess: true,
    nReturned: 1,
    executionTimeMillis: 0,
    totalKeysExamined: 1,
    totalDocsExamined: 1,
    executionStages: {
      stage: 'FETCH',
      nReturned: 1,
      works: 2,
      advanced: 1,
      isEOF: 1,
      docsExamined: 1,
      inputStage: {
        stage: 'IXSCAN',
        nReturned: 1,
        works: 2,
        advanced: 1,
        isEOF: 1,
        keyPattern: {
          'doctors.updated': 1,
          _id: 1
        },
        indexName: 'doctors.updated_1__id_1',
        isMultiKey: true,
        multiKeyPaths: {
          'doctors.updated': [
            'doctors'
          ],
          _id: []
        },
        direction: 'forward',
        indexBounds: {
          'doctors.updated': [
            '[new Date(1770384644918), new Date(9223372036854775807)]'
          ],
          _id: [
            '[MinKey, MaxKey]'
          ]
        },
        keysExamined: 1,
        seeks: 1,
      }
    }
  },
Enter fullscreen mode Exit fullscreen mode

Conclusion

MongoDB's document model with optimistic concurrency control per document avoid write-skew in the "Doctor's On-Call Shift" problem by embedding business rules in a conditional atomic updateOne() operation. Using $elemMatch and arrayFilters to implement a semi-join within the embedded array ensures that concurrent updates to the same shift document follow a first‑successful‑writer wins policy, eliminating race conditions without explicit locks, serializable isolation, or SQL assertions.

This approach leverages MongoDB's strengths:

  • Atomic conditional updates that read and write in one step
  • Optimistic write conflict handling
  • Schema validation as an extra integrity check
  • Flexible indexing on fields inside embedded arrays

By embedding all doctors for a shift in a single document, MongoDB allows the invariant to be enforced inside a single atomic update. This eliminates the write‑skew anomaly that occurs under snapshot isolation when the invariant spans multiple rows.

Schema validation alone cannot prevent race conditions, but together with conditional updates, it protects against concurrency anomalies and direct data corruption.

This pattern shows how MongoDB’s document model simplifies concurrency issues that, in relational databases, often require advanced isolation levels or explicit locking due to normalized one-to-many relationships across multiple tables. By co-locating related data and using atomic operations, you can maintain integrity at scale.

Top comments (0)