Implementing the Outlier Pattern with Safe Overflow
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
| Metric | Unbounded (10K activities) | Outlier pattern |
|---|---|---|
| Write latency (push) | 8.5ms | 0.12ms |
| Read latency (first page) | 45ms | 12ms (primary only) |
| Document size (power user) | 4 MB (growing) | 400 KB (capped) |
| Risk of 16 MB limit | Guaranteed at 40K activities | None |
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.