Skip to content

Commit 40499e9

Browse files
committed
MDH: stable _id sort in download to prevent duplicate rows across batches
The collection download issues N concurrent aggregate calls with $skip/$limit and no explicit sort, relying on MongoDB's natural order. That order is not guaranteed to be stable across separate aggregate executions, so adjacent windows can overlap (duplicate _ids in the output file) and other docs fall in the gap and are missed entirely. Append {$sort: {_id: 1}} to every per-batch pipeline unless the caller's pipelineStages already end in $sort. The _id index is always present so the sort is an index scan with no extra cost. Caller-supplied sorts (e.g. from "Download filtered") are preserved; the header comment now recommends including _id as a tie-breaker when sorting on a non-unique field.
1 parent c2d0b7a commit 40499e9

2 files changed

Lines changed: 68 additions & 5 deletions

File tree

src/mdh/downloadCollection.js

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ import * as api from './api.js';
3131
// inside the buffer-room wait. The streaming file, if any, is aborted on
3232
// cancel so the partial file is discarded rather than left as half-valid
3333
// JSON on the user's disk.
34+
//
35+
// Stable ordering across batches: each worker issues its OWN aggregate
36+
// call, and MongoDB does not guarantee a stable natural order across
37+
// independent aggregations on the same collection. Without an explicit
38+
// sort, adjacent $skip/$limit windows can overlap (the same doc appears
39+
// in two batches) AND leave gaps (other docs missed entirely). We append
40+
// {$sort: {_id: 1}} to the pipeline when the caller hasn't provided
41+
// their own sort, so every worker scans in the same deterministic order.
42+
// {_id: 1} uses the always-present _id index, so this is free.
3443

3544
export const BATCH_SIZE = 1000;
3645
export const CONCURRENCY = 10;
@@ -52,7 +61,10 @@ export async function downloadCollection(collectionName, opts = {}) {
5261
// Prepended to every batch's aggregate call. Default downloads the raw
5362
// collection; pass `[{$match: ...}, {$sort: ...}, ...]` to export the
5463
// result of a filtered/transformed pipeline. The downloader appends its
55-
// own `$skip` / `$limit` per batch — callers should strip those.
64+
// own `$sort` (if absent), `$skip`, and `$limit` per batch — callers
65+
// should strip those. Callers sorting on a non-unique field should
66+
// include `_id` as a tie-breaker (e.g. `{$sort: {name: 1, _id: 1}}`)
67+
// to keep batch boundaries stable.
5668
pipelineStages = [{ $match: {} }],
5769
filename: filenameOpt,
5870
} = opts;
@@ -86,6 +98,14 @@ export async function downloadCollection(collectionName, opts = {}) {
8698
const offsets = [];
8799
for (let s = 0; s < total; s += batchSize) offsets.push(s);
88100

101+
// Inject a deterministic sort unless the caller already ended their
102+
// pipeline with one. Without this, separate aggregate calls can iterate
103+
// the collection in different orders and the workers' $skip/$limit
104+
// windows overlap.
105+
const stages = pipelineEndsWithSort(pipelineStages)
106+
? pipelineStages
107+
: [...pipelineStages, { $sort: { _id: 1 } }];
108+
89109
const parts = [];
90110
let docsWritten = 0;
91111
let fetched = 0;
@@ -150,7 +170,7 @@ export async function downloadCollection(collectionName, opts = {}) {
150170

151171
try {
152172
const res = await api.aggregate(collectionName, [
153-
...pipelineStages,
173+
...stages,
154174
{ $skip: myOffset },
155175
{ $limit: batchSize },
156176
]);
@@ -203,6 +223,12 @@ function formatDoc(doc) {
203223
return ' ' + JSON.stringify(doc, null, 2).replace(/\n/g, '\n ');
204224
}
205225

226+
function pipelineEndsWithSort(stages) {
227+
if (!Array.isArray(stages) || stages.length === 0) return false;
228+
const last = stages[stages.length - 1];
229+
return last && typeof last === 'object' && Object.prototype.hasOwnProperty.call(last, '$sort');
230+
}
231+
206232
async function safeAbort(writer) {
207233
if (!writer || typeof writer.abort !== 'function') return;
208234
try { await writer.abort('cancelled'); } catch { /* writer may already be closed */ }

tests/mdh-download-collection.test.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,49 @@ describe('downloadCollection — streaming (FileSystem Access) path', () => {
7373

7474
expect(result.fetched).toBe(2500);
7575
expect(api.aggregate).toHaveBeenCalledTimes(3);
76-
expect(api.aggregate).toHaveBeenNthCalledWith(1, 'big', [{ $match: {} }, { $skip: 0 }, { $limit: 1000 }]);
77-
expect(api.aggregate).toHaveBeenNthCalledWith(2, 'big', [{ $match: {} }, { $skip: 1000 }, { $limit: 1000 }]);
78-
expect(api.aggregate).toHaveBeenNthCalledWith(3, 'big', [{ $match: {} }, { $skip: 2000 }, { $limit: 1000 }]);
76+
// Every batch must include the {$sort: {_id: 1}} we inject — without it,
77+
// MongoDB's natural order isn't stable across separate aggregate calls
78+
// and adjacent windows overlap, producing duplicate _ids in the output.
79+
expect(api.aggregate).toHaveBeenNthCalledWith(1, 'big', [{ $match: {} }, { $sort: { _id: 1 } }, { $skip: 0 }, { $limit: 1000 }]);
80+
expect(api.aggregate).toHaveBeenNthCalledWith(2, 'big', [{ $match: {} }, { $sort: { _id: 1 } }, { $skip: 1000 }, { $limit: 1000 }]);
81+
expect(api.aggregate).toHaveBeenNthCalledWith(3, 'big', [{ $match: {} }, { $sort: { _id: 1 } }, { $skip: 2000 }, { $limit: 1000 }]);
7982
const parsed = JSON.parse(writer.chunks.join(''));
8083
expect(parsed).toEqual(docs);
8184
});
8285

86+
it('preserves a caller-provided sort instead of overriding it', async () => {
87+
api.aggregate.mockResolvedValueOnce({ result: [{ _id: 1 }] });
88+
const writer = fakeWriter();
89+
await downloadCollection('c', {
90+
fetchCount: async () => 1,
91+
pipelineStages: [{ $match: { status: 'open' } }, { $sort: { name: 1, _id: 1 } }],
92+
pickFile: () => Promise.resolve(fakeHandle(writer)),
93+
});
94+
expect(api.aggregate).toHaveBeenCalledWith('c', [
95+
{ $match: { status: 'open' } },
96+
{ $sort: { name: 1, _id: 1 } },
97+
{ $skip: 0 },
98+
{ $limit: 1000 },
99+
]);
100+
});
101+
102+
it('appends the trailing _id sort when the caller\'s pipeline ends with a non-sort stage', async () => {
103+
api.aggregate.mockResolvedValueOnce({ result: [{ _id: 1 }] });
104+
const writer = fakeWriter();
105+
await downloadCollection('c', {
106+
fetchCount: async () => 1,
107+
pipelineStages: [{ $match: { active: true } }, { $project: { name: 1 } }],
108+
pickFile: () => Promise.resolve(fakeHandle(writer)),
109+
});
110+
expect(api.aggregate).toHaveBeenCalledWith('c', [
111+
{ $match: { active: true } },
112+
{ $project: { name: 1 } },
113+
{ $sort: { _id: 1 } },
114+
{ $skip: 0 },
115+
{ $limit: 1000 },
116+
]);
117+
});
118+
83119
it('writes batches in source order even when later batches resolve first', async () => {
84120
const d0 = defer();
85121
const d1 = defer();
@@ -484,6 +520,7 @@ describe('downloadCollection — pipelineStages option (filtered download)', ()
484520
});
485521
expect(api.aggregate).toHaveBeenCalledWith('c', [
486522
{ $match: {} },
523+
{ $sort: { _id: 1 } },
487524
{ $skip: 0 },
488525
{ $limit: 1000 },
489526
]);

0 commit comments

Comments
 (0)