Laputa

Write Atomicity in MongoDB

Recently, I’ve been building a task system with the following workflow:

  1. A user submits a task.
  2. The task is immediately stored in MongoDB.
  3. The task ID is returned to the user, who can poll for its status.
  4. A background worker periodically fetches pending tasks and executes them.

This model is quite common when task execution can take a long time.

However, here’s the catch: the service is deployed across multiple instances, and they may all fetch the same pending tasks from MongoDB. This creates a race condition — a task could be executed multiple times, and the final result becomes unpredictable.

Redis Lock?

A common solution is the Redis distributed lock: All instances fetch the same task list, but only the one that acquires the lock is allowed to execute it, while the others back off.

While this works, it introduces extra complexity and a new infrastructure dependency.

MongoDB Write Atomicity

MongoDB provides a cleaner alternative with its per-document write atomicity. According to the documentation:

In MongoDB, a write operation is atomic on the level of a single document, even if the operation modifies multiple fields. When multiple update commands happen in parallel, each individual command ensures that the query condition still matches.

Let’s consider this document:

1
db.games.insertOne({ _id: 1, score: 80 })

And two concurrent updates:

1
2
3
4
5
// Update A
db.games.updateOne({ score: 80 }, { $set: { score: 90 } })

// Update B
db.games.updateOne({ score: 80 }, { $set: { score: 100 } })

These updates are conditional on score == 80. Due to atomicity, only one will succeed, and the other will silently fail to match the filter. So the final score will be either 90 or 100 — never both.

Now compare this with:

1
2
3
4
5
// Update A
db.games.updateOne({ _id: 1 }, { $set: { score: 90 } })

// Update B
db.games.updateOne({ _id: 1 }, { $set: { score: 100 } })

Both updates match the same _id, and since the filter doesn’t change, both succeed, and the result depends on timing.

So the trick is to include the field you’re going to change in the filter. Once the first update changes it, others can’t match it anymore.

Real Case: claimTask

Based on this behavior, I implemented claimTask — an atomic operation to safely claim a task in a multi-instance setup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (w *Worker) claimTask(ctx context.Context, task *model.Task) (bool, error) {
    now := time.Now().Unix()
    filter := bson.M{"_id": task.ID, "status": model.TASKSTATUSCREATED}
    update := bson.M{
        "$set": bson.M{
            "status":     model.TASKSTATUSRUNNING,
            "claimed_at": now,
        },
    }
    res, err := w.taskCol.UpdateOne(ctx, filter, update)
    if err != nil {
        return false, err
    }
    if res.ModifiedCount == 1 {
        task.Status = model.TASKSTATUSRUNNING
        task.ClaimedAt = now
        return true, nil
    }
    return false, nil
}

By checking res.ModifiedCount, we know whether the current instance successfully claimed the task. If not, we simply skip it.

Here’s how it fits into our batch fetch loop:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func fetchBatchTasks(ctx context.Context, limit int) ([]*model.Task, error) {
    // query CREATED tasks
    var tasks []*model.Task
    for cursor.Next(ctx) {
        var t model.Task
        if err := cursor.Decode(&t); err != nil {
            continue
        }

        ok, err := w.claimTask(ctx, &t)
        if err != nil || !ok {
            continue
        }
        tasks = append(tasks, &t)
    }
    return tasks, nil
}

By leveraging MongoDB’s built-in atomic updateOne with a smart filter, we avoid introducing extra dependency and keep the code clean.

#MongoDB #Atomic #Concurrency #Backend