Skip to main content
unbound mongodb at scale

Implementing the Outlier Pattern with Safe Overflow

5 min read Chapter 23 of 72

Implementing the Outlier Pattern with Safe Overflow

The Symptom

After implementing the basic outlier pattern from the chapter introduction, the team discovers a race condition. Two concurrent requests check activityCount, both see 999, both push to the primary document. The document now has 1,001 activities and no overflow flag. If this happens repeatedly, the primary document grows past the threshold.

The Cause

The check-then-act pattern (if currentCount < 1000 then push) is not atomic. Between the find() and the updateOne(), another thread can modify the count. The MongoDB Java driver’s findOneAndUpdate is atomic on a single document, but the pattern requires reading one field and conditionally writing to different collections.

The Benchmark

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 3, time = 5)
@Measurement(iterations = 5, time = 10)
@Fork(1)
@State(Scope.Benchmark)
public class OutlierPatternBenchmark {

    private MongoCollection<Document> unboundedCollection;
    private MongoCollection<Document> boundedCollection;
    private MongoCollection<Document> overflowCollection;

    @Param({"100", "1000", "5000", "10000"})
    private int existingArraySize;

    @Setup(Level.Iteration)
    public void setup() {
        MongoClient client = MongoClients.create("mongodb://localhost:27017");
        var db = client.getDatabase("bench");
        unboundedCollection = db.getCollection("unbounded");
        boundedCollection = db.getCollection("bounded");
        overflowCollection = db.getCollection("overflow");

        unboundedCollection.drop();
        boundedCollection.drop();
        overflowCollection.drop();

        // Create documents with pre-filled arrays
        List<Document> activities = new ArrayList<>();
        for (int i = 0; i < existingArraySize; i++) {
            activities.add(new Document("type", "like").append("ts", new Date()));
        }

        unboundedCollection.insertOne(new Document("_id", "user-1")
            .append("activities", activities)
            .append("activityCount", existingArraySize));

        List<Document> bounded = activities.subList(0, Math.min(1000, existingArraySize));
        boundedCollection.insertOne(new Document("_id", "user-1")
            .append("activities", bounded)
            .append("activityCount", existingArraySize)
            .append("hasOverflow", existingArraySize > 1000));

        if (existingArraySize > 1000) {
            int pages = (existingArraySize - 1000) / 1000 + 1;
            for (int p = 0; p < pages; p++) {
                int start = 1000 + p * 1000;
                int end = Math.min(start + 1000, existingArraySize);
                overflowCollection.insertOne(new Document()
                    .append("userId", "user-1")
                    .append("page", p)
                    .append("activities", activities.subList(start, end)));
            }
        }
    }

    @Benchmark
    public UpdateResult pushToUnbounded() {
        return unboundedCollection.updateOne(
            Filters.eq("_id", "user-1"),
            Updates.push("activities", new Document("type", "post").append("ts", new Date()))
        );
    }

    @Benchmark
    public UpdateResult pushToBounded() {
        int page = existingArraySize / 1000;
        if (existingArraySize >= 1000) {
            return overflowCollection.updateOne(
                Filters.and(Filters.eq("userId", "user-1"), Filters.eq("page", page)),
                Updates.combine(
                    Updates.push("activities", new Document("type", "post").append("ts", new Date())),
                    Updates.inc("count", 1)
                ),
                new UpdateOptions().upsert(true)
            );
        }
        return boundedCollection.updateOne(
            Filters.eq("_id", "user-1"),
            Updates.push("activities", new Document("type", "post").append("ts", new Date()))
        );
    }
}

Results:

Benchmark                              (existingArraySize)  Mode  Cnt      Score      Error  Units
OutlierPatternBenchmark.pushToUnbounded              100  avgt    5     85.000 ±    8.000  us/op
OutlierPatternBenchmark.pushToUnbounded             1000  avgt    5    320.000 ±   25.000  us/op
OutlierPatternBenchmark.pushToUnbounded             5000  avgt    5   2800.000 ±  180.000  us/op
OutlierPatternBenchmark.pushToUnbounded            10000  avgt    5   8500.000 ±  600.000  us/op
OutlierPatternBenchmark.pushToBounded               100  avgt    5     88.000 ±    9.000  us/op
OutlierPatternBenchmark.pushToBounded              1000  avgt    5    115.000 ±   12.000  us/op
OutlierPatternBenchmark.pushToBounded              5000  avgt    5    120.000 ±   10.000  us/op
OutlierPatternBenchmark.pushToBounded             10000  avgt    5    118.000 ±   11.000  us/op

With 10,000 existing activities, unbounded $push takes 8,500us (8.5ms). Bounded overflow takes 118us. That is 72x faster because overflow writes go to a small document (the current overflow page), not to a 10 MB primary document.

The Fix

Use findOneAndUpdate with a conditional update to make the overflow detection atomic:

// FAST: Atomic overflow pattern
public void addActivitySafe(String userId, Activity activity) {
    Document activityDoc = activityToDocument(activity);

    // Attempt to push to primary document if under threshold
    Document result = users.findOneAndUpdate(
        Filters.and(
            Filters.eq("_id", userId),
            Filters.lt("activityCount", 1000)
        ),
        Updates.combine(
            Updates.push("activities", activityDoc),
            Updates.inc("activityCount", 1)
        ),
        new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)
    );

    if (result != null) {
        return;  // Pushed to primary document successfully
    }

    // Primary is full or over threshold: write to overflow
    Document userDoc = users.find(Filters.eq("_id", userId)).first();
    int totalCount = userDoc.getInteger("activityCount", 0);
    int page = (totalCount - 1000) / 1000;

    overflowActivities.updateOne(
        Filters.and(
            Filters.eq("userId", userId),
            Filters.eq("page", page)
        ),
        Updates.combine(
            Updates.push("activities", activityDoc),
            Updates.inc("count", 1),
            Updates.setOnInsert("userId", userId),
            Updates.setOnInsert("page", page)
        ),
        new UpdateOptions().upsert(true)
    );

    users.updateOne(
        Filters.eq("_id", userId),
        Updates.combine(
            Updates.set("hasOverflow", true),
            Updates.inc("activityCount", 1),
            Updates.max("overflowPages", page + 1)
        )
    );
}

The findOneAndUpdate with Filters.lt("activityCount", 1000) only matches if the count is below threshold. If two threads race, one succeeds and increments the count, the other’s filter does not match and it falls through to the overflow path.

Reading activities across primary and overflow:

// FAST: Read all activities for a user (paginated)
public List<Activity> getActivities(String userId, int skip, int limit) {
    Document userDoc = users.find(Filters.eq("_id", userId))
        .projection(Projections.include("activities", "hasOverflow", "overflowPages"))
        .first();

    List<Document> primaryActivities = userDoc.getList("activities", Document.class);

    if (!userDoc.getBoolean("hasOverflow", false) || skip + limit <= primaryActivities.size()) {
        // All requested activities are in the primary document
        return primaryActivities.stream()
            .sorted(Comparator.comparing(d -> d.getDate("ts"), Comparator.reverseOrder()))
            .skip(skip)
            .limit(limit)
            .map(this::documentToActivity)
            .toList();
    }

    // Need overflow documents
    List<Document> allActivities = new ArrayList<>(primaryActivities);
    overflowActivities.find(Filters.eq("userId", userId))
        .sort(Sorts.ascending("page"))
        .forEach(doc -> allActivities.addAll(doc.getList("activities", Document.class)));

    return allActivities.stream()
        .sorted(Comparator.comparing(d -> d.getDate("ts"), Comparator.reverseOrder()))
        .skip(skip)
        .limit(limit)
        .map(this::documentToActivity)
        .toList();
}

The Proof

MetricUnbounded (10K activities)Outlier pattern
Write latency (push)8.5ms0.12ms
Read latency (first page)45ms12ms (primary only)
Document size (power user)4 MB (growing)400 KB (capped)
Risk of 16 MB limitGuaranteed at 40K activitiesNone

The Trade-off

The outlier pattern adds application complexity. The read path must check hasOverflow and potentially query two collections. For paginated reads that only show the first page (the most common case), the overhead is zero because the first 1,000 activities are in the primary document. For deep pagination into overflow pages, the read requires multiple queries. Index the overflow collection on {userId: 1, page: 1} for efficient page lookups.

The threshold of 1,000 is not universal. Calculate it from your average activity document size and your target primary document size. If each activity is 200 bytes, 1,000 activities is 200 KB. That leaves ample room for other user fields. If each activity is 2 KB (with embedded content), cap at 100 to keep the primary document under 200 KB.