Skip to content

Commit 62016e9

Browse files
committed
Test bucket report row sampling and handle empty samples
1 parent 00ed2de commit 62016e9

4 files changed

Lines changed: 124 additions & 50 deletions

File tree

modules/module-mongodb-storage/src/storage/implementation/MongoSyncBucketStorage.ts

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -576,40 +576,52 @@ export abstract class MongoSyncBucketStorage
576576
/**
577577
* Estimate a bucket's live rows from a sample of its operations.
578578
*
579-
* `pipelinePrefix` must select the bucket's operations (and, when `sampled`, randomly down-sample them) and
580-
* yield documents with top-level `op`, `table` and `row_id` fields. Returns the distinct row count (exact
581-
* when the whole bucket was read, otherwise estimated via {@link storage.estimateDistinctRows}); fragmentation is
582-
* then `operations / rows`.
579+
* `buildPrefix(applySample)` returns a pipeline prefix that selects the bucket's operations (down-sampled
580+
* when `applySample` is true) and yields documents with top-level `op`, `table` and `row_id` fields.
581+
* Returns the distinct row count (exact when the whole bucket was read, otherwise estimated via
582+
* {@link storage.estimateDistinctRows}); fragmentation is then `operations / rows`.
583583
*/
584584
protected async estimateRowsFromOperationSample<T extends mongo.Document>(
585585
collection: mongo.Collection<T>,
586-
pipelinePrefix: mongo.Document[],
586+
buildPrefix: (applySample: boolean) => mongo.Document[],
587587
operations: number,
588588
sampled: boolean
589589
): Promise<BucketRowEstimate> {
590-
const pipeline: mongo.Document[] = [
591-
...pipelinePrefix,
592-
{
593-
$facet: {
594-
sampledOps: [{ $count: 'count' }],
595-
distinctRows: [
596-
{ $match: { op: { $in: ['PUT', 'REMOVE'] } } },
597-
{ $group: { _id: { table: '$table', row_id: '$row_id' } } },
598-
{ $count: 'count' }
599-
]
590+
const runCounts = async (applySample: boolean) => {
591+
const pipeline: mongo.Document[] = [
592+
...buildPrefix(applySample),
593+
{
594+
$facet: {
595+
sampledOps: [{ $count: 'count' }],
596+
distinctRows: [
597+
{ $match: { op: { $in: ['PUT', 'REMOVE'] } } },
598+
{ $group: { _id: { table: '$table', row_id: '$row_id' } } },
599+
{ $count: 'count' }
600+
]
601+
}
600602
}
601-
}
602-
];
603-
604-
type FacetResult = { sampledOps: { count: number }[]; distinctRows: { count: number }[] };
605-
const [result] = await collection
606-
.aggregate<FacetResult>(pipeline, { allowDiskUse: false, maxTimeMS: storage.BUCKET_REPORT_TIMEOUT_MS })
607-
.toArray();
603+
];
604+
type FacetResult = { sampledOps: { count: number }[]; distinctRows: { count: number }[] };
605+
const [result] = await collection
606+
.aggregate<FacetResult>(pipeline, { allowDiskUse: false, maxTimeMS: storage.BUCKET_REPORT_TIMEOUT_MS })
607+
.toArray();
608+
return {
609+
sampledOps: result?.sampledOps[0]?.count ?? 0,
610+
distinctRows: result?.distinctRows[0]?.count ?? 0
611+
};
612+
};
608613

609-
const sampledOps = result?.sampledOps[0]?.count ?? 0;
610-
const distinctRows = result?.distinctRows[0]?.count ?? 0;
611-
if (sampledOps == 0 || distinctRows == 0) {
612-
// Nothing row-bearing was sampled (e.g. a bucket of only MOVE/CLEAR ops): treat as fully fragmented.
614+
let { sampledOps, distinctRows } = await runCounts(sampled);
615+
if (sampled && sampledOps == 0) {
616+
// A document-level `$sampleRate` can select nothing when a bucket spans very few storage documents
617+
// (v3 batches operations into a document). Fall back to an exact read so the bucket is not reported as
618+
// zero rows. This reads the whole bucket only in the rare empty-sample case, which cannot happen for a
619+
// bucket large enough to span many documents.
620+
distinctRows = (await runCounts(false)).distinctRows;
621+
return { rows: distinctRows, estimated: false };
622+
}
623+
if (distinctRows == 0) {
624+
// Nothing row-bearing was found (e.g. a bucket of only MOVE/CLEAR ops): treat as fully fragmented.
613625
return { rows: 0, estimated: sampled };
614626
}
615627
if (!sampled) {

modules/module-mongodb-storage/src/storage/implementation/v1/MongoSyncBucketStorageV1.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -204,22 +204,25 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
204204
protected estimateBucketRows(candidate: TopBucketCandidate): Promise<BucketRowEstimate> {
205205
// v1/v2 store one document per operation, so a bucket's ops are an id-prefix range that can be sampled directly.
206206
const sampled = this.shouldSampleBucketRows(candidate.operations);
207-
// Range-match on the whole `_id` (g, b, o) so the {_id} index is used; a dotted `{'_id.g','_id.b'}` match
208-
// cannot use the compound-object index and would scan the whole collection per bucket.
209-
const prefix: mongo.Document[] = [
210-
{
211-
$match: {
212-
_id: idPrefixFilter<{ g: number; b: string; o: unknown }>(
213-
{ g: this.replicationStreamId, b: candidate.bucket },
214-
['o']
215-
)
207+
const buildPrefix = (applySample: boolean): mongo.Document[] => {
208+
// Range-match on the whole `_id` (g, b, o) so the {_id} index is used; a dotted `{'_id.g','_id.b'}` match
209+
// cannot use the compound-object index and would scan the whole collection per bucket.
210+
const prefix: mongo.Document[] = [
211+
{
212+
$match: {
213+
_id: idPrefixFilter<{ g: number; b: string; o: unknown }>(
214+
{ g: this.replicationStreamId, b: candidate.bucket },
215+
['o']
216+
)
217+
}
216218
}
219+
];
220+
if (applySample) {
221+
prefix.push({ $match: { $sampleRate: this.bucketRowSampleRate(candidate.operations) } });
217222
}
218-
];
219-
if (sampled) {
220-
prefix.push({ $match: { $sampleRate: this.bucketRowSampleRate(candidate.operations) } });
221-
}
222-
return this.estimateRowsFromOperationSample(this.db.bucketDataV1, prefix, candidate.operations, sampled);
223+
return prefix;
224+
};
225+
return this.estimateRowsFromOperationSample(this.db.bucketDataV1, buildPrefix, candidate.operations, sampled);
223226
}
224227

225228
protected createMongoParameterCompactor(

modules/module-mongodb-storage/src/storage/implementation/v3/MongoSyncBucketStorageV3.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -214,16 +214,19 @@ export class MongoSyncBucketStorageV3 extends MongoSyncBucketStorage {
214214
// Sample whole batch documents, then unwind to operation level so the shared estimator sees one doc per op.
215215
const sampled = this.shouldSampleBucketRows(candidate.operations);
216216
const collection = this.db.bucketData(this.replicationStreamId, candidate.defId!);
217-
// Range-match on the whole `_id` (b, o) so the {_id} index is used; a dotted `{'_id.b': ...}` match
218-
// cannot use the compound-object index and would scan the whole collection per bucket.
219-
const prefix: mongo.Document[] = [
220-
{ $match: { _id: idPrefixFilter<{ b: string; o: unknown }>({ b: candidate.bucket }, ['o']) } }
221-
];
222-
if (sampled) {
223-
prefix.push({ $match: { $sampleRate: this.bucketRowSampleRate(candidate.operations) } });
224-
}
225-
prefix.push({ $unwind: '$ops' }, { $replaceRoot: { newRoot: '$ops' } });
226-
return this.estimateRowsFromOperationSample(collection, prefix, candidate.operations, sampled);
217+
const buildPrefix = (applySample: boolean): mongo.Document[] => {
218+
// Range-match on the whole `_id` (b, o) so the {_id} index is used; a dotted `{'_id.b': ...}` match
219+
// cannot use the compound-object index and would scan the whole collection per bucket.
220+
const prefix: mongo.Document[] = [
221+
{ $match: { _id: idPrefixFilter<{ b: string; o: unknown }>({ b: candidate.bucket }, ['o']) } }
222+
];
223+
if (applySample) {
224+
prefix.push({ $match: { $sampleRate: this.bucketRowSampleRate(candidate.operations) } });
225+
}
226+
prefix.push({ $unwind: '$ops' }, { $replaceRoot: { newRoot: '$ops' } });
227+
return prefix;
228+
};
229+
return this.estimateRowsFromOperationSample(collection, buildPrefix, candidate.operations, sampled);
227230
}
228231

229232
protected createMongoParameterCompactor(

packages/service-core-tests/src/tests/register-bucket-report-tests.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,60 @@ bucket_definitions:
210210
expect(report.totals.bucketCount).toEqual(2);
211211
expect(report.totals).toMatchObject({ operations: 3, estimated: false });
212212
});
213+
214+
test('samples the row count for a bucket above the sampling threshold', async () => {
215+
await using factory = await generateStorageFactory();
216+
const { stream, content } = await test_utils.deploySyncRules(
217+
factory,
218+
updateSyncRulesFromYaml(GLOBAL_SYNC_RULES, { storageVersion })
219+
);
220+
const bucketStorage = factory.getInstance(stream);
221+
222+
await using writer = await bucketStorage.createWriter(test_utils.BATCH_OPTIONS);
223+
const testTable = await test_utils.resolveTestTable(writer, 'test', ['id'], config);
224+
await writer.markAllSnapshotDone('1/1');
225+
226+
// 50 rows, each updated 25 times, is 1,300 operations against 50 live rows. That is past the 1,000
227+
// operation threshold, so the report samples the operation history rather than reading it in full and
228+
// the row count comes back as an estimate. The value per update varies so no two writes are identical.
229+
// Each round is flushed separately so the operations span many storage documents, as they would in real
230+
// replication (some backends batch operations per document, and a sample must see more than one).
231+
const rowCount = 50;
232+
const updatesPerRow = 25;
233+
for (let row = 0; row < rowCount; row++) {
234+
await writer.save({
235+
sourceTable: testTable,
236+
tag: storage.SaveOperationTag.INSERT,
237+
after: { id: `r${row}` },
238+
afterReplicaId: test_utils.rid(`r${row}`)
239+
});
240+
}
241+
await writer.commit('1/1');
242+
await writer.flush();
243+
for (let update = 0; update < updatesPerRow; update++) {
244+
for (let row = 0; row < rowCount; row++) {
245+
await writer.save({
246+
sourceTable: testTable,
247+
tag: storage.SaveOperationTag.UPDATE,
248+
after: { id: `r${row}`, value: `v${update}` },
249+
afterReplicaId: test_utils.rid(`r${row}`)
250+
});
251+
}
252+
await writer.commit('1/1');
253+
await writer.flush();
254+
}
255+
256+
const bucket = test_utils.bucketRequest(content, 'global[]').bucket;
257+
const report = await getReport(bucketStorage);
258+
const stats = report.buckets.find((b) => b.bucket === bucket)!;
259+
260+
// The operation count is exact (read from bucket_state); the row count is a sampled estimate.
261+
expect(stats.operations).toEqual(rowCount + rowCount * updatesPerRow);
262+
expect(stats.rowsEstimated).toEqual(true);
263+
// The sample covers enough of a bucket this fragmented to recover the 50 live rows within a small margin.
264+
expect(stats.rows).toBeGreaterThanOrEqual(45);
265+
expect(stats.rows).toBeLessThanOrEqual(55);
266+
// Fragmentation is operations / rows, so a heavily updated bucket reads well above 1.
267+
expect(stats.fragmentation).toBeGreaterThan(10);
268+
});
213269
}

0 commit comments

Comments
 (0)