Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
a3d7ed7
Add bucket storage report types and builder
bean1352 Jun 23, 2026
8b3a0a3
Implement bucket report for MongoDB storage
bean1352 Jun 23, 2026
0c89494
Implement bucket report for Postgres storage
bean1352 Jun 23, 2026
4e8298c
Add bucket-report admin endpoint
bean1352 Jun 23, 2026
73f5ed6
Add bucket report tests and changeset
bean1352 Jun 23, 2026
3864470
Add bucket report query timeout constant
bean1352 Jun 24, 2026
eb9b614
Scope v3 bucket report to active config and bound Mongo queries
bean1352 Jun 24, 2026
35aeb98
Bound Postgres bucket report with a statement timeout
bean1352 Jun 24, 2026
9575b7e
Return a friendly timeout error from the bucket report
bean1352 Jun 24, 2026
e0525ed
Merge branch 'main' into feat/bucket-storage-report
bean1352 Jun 25, 2026
550fa20
Add instance-wide fragmentation to bucket report totals
bean1352 Jun 25, 2026
299a80a
Clamp bucket report limit and narrow the Mongo timeout catch
bean1352 Jun 25, 2026
bda1d06
Clarify bucket report API docs and add service-errors changeset
bean1352 Jun 25, 2026
bc9384d
Add bucket report route test and strengthen storage tests
bean1352 Jun 25, 2026
895fbd0
Merge branch 'main' into feat/bucket-storage-report
bean1352 Jun 25, 2026
b02ec7d
Rework bucket report contract for top-N sampling
bean1352 Jun 29, 2026
65ea0c6
Sample MongoDB bucket report instead of scanning all storage
bean1352 Jun 29, 2026
98297de
Limit bucket report to MongoDB storage
bean1352 Jun 29, 2026
cf1b790
Update bucket report tests for the sampling contract
bean1352 Jun 29, 2026
75825bc
Improve bucket report row estimate and sample buckets concurrently
bean1352 Jun 29, 2026
dc62a89
Merge branch 'main' into feat/bucket-storage-report
bean1352 Jun 29, 2026
3685022
Improve bucket report row and total estimates
bean1352 Jul 1, 2026
6a7d10f
Use the _id index for bucket report row sampling
bean1352 Jul 1, 2026
d09548a
Clarify bucket report limit docs
bean1352 Jul 1, 2026
348d152
Merge branch 'main' into feat/bucket-storage-report
bean1352 Jul 1, 2026
00ed2de
Fix bucket report comments and collection typing
bean1352 Jul 1, 2026
62016e9
Test bucket report row sampling and handle empty samples
bean1352 Jul 1, 2026
674bd85
Clean up comments for readability
bean1352 Jul 2, 2026
d4eb5cc
Exclude compaction MOVE ops from bucket report row estimates
bean1352 Jul 2, 2026
7d8fa99
Add definition rollup, action suggestions, and tables to the bucket r…
bean1352 Jul 2, 2026
c1c3999
Raise the bucket report action threshold to 3x fragmentation
bean1352 Jul 2, 2026
784ab81
Trim the fragmentation threshold comment
bean1352 Jul 2, 2026
f4ea6e5
Reject invalid bucket report limits instead of clamping
bean1352 Jul 6, 2026
e0ad538
Sample the bucket ranking on the index without fetching unsampled buc…
bean1352 Jul 6, 2026
606848c
Merge branch 'main' into feat/bucket-storage-report
bean1352 Aug 3, 2026
6a54814
Merge origin/main into feat/bucket-storage-report
bean1352 Aug 21, 2026
2d5d7b8
Merge origin/feat/bucket-storage-report into feat/bucket-storage-report
bean1352 Aug 25, 2026
a8434da
Merge branch 'main' into feat/bucket-storage-report
bean1352 Aug 25, 2026
e706504
Derive bucket report rows from full compact statistics
bean1352 Aug 26, 2026
a20cf9b
Compute bucket reports from bucket_state only
bean1352 Aug 26, 2026
b44e722
Add uncompacted operations and compact times to the bucket report API
bean1352 Aug 26, 2026
90f43b4
Update bucket report tests for compact derived stats
bean1352 Aug 26, 2026
40230b1
Export BucketReportTotals type
bean1352 Aug 26, 2026
4071a32
Merge branch 'main' into feat/bucket-storage-report
bean1352 Aug 26, 2026
eb66b39
Default bucket report reads to secondaryPreferred
bean1352 Aug 26, 2026
f00e343
Reuse mongo timeout constant and test helpers
bean1352 Aug 26, 2026
3b61305
Add bucket report sampling test
bean1352 Aug 26, 2026
074cbf6
Use enumLiteral and orNull codec helpers
bean1352 Aug 26, 2026
3483bea
Merge branch 'main' into feat/bucket-storage-report
bean1352 Aug 26, 2026
b197fc8
Format bucket report tests
bean1352 Aug 27, 2026
4f0ccfd
Merge branch 'main' into feat/bucket-storage-report
bean1352 Aug 31, 2026
631f5e0
Merge branch 'main' into feat/bucket-storage-report
bean1352 Sep 1, 2026
082868c
Merge branch 'main' into feat/bucket-storage-report
bean1352 Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/bucket-storage-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@powersync/service-core': minor
'@powersync/service-types': minor
'@powersync/service-module-mongodb-storage': minor
'@powersync/service-core-tests': minor
'@powersync/service-client': minor
---

Add a `POST /api/admin/v1/bucket-report` admin endpoint reporting per-bucket operation counts, with rows and fragmentation derived from each bucket's last full compact (MongoDB storage; storage v1/v2 report operation counts only).
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {
BaseObserver,
logger as defaultLogger,
DO_NOT_LOG,
ErrorCode,
Logger,
ReplicationAbortedError,
ServiceAssertionError
ServiceAssertionError,
ServiceError
} from '@powersync/lib-services-framework';
import {
BroadcastIterable,
Expand Down Expand Up @@ -82,6 +84,59 @@ interface InternalCheckpointChanges extends CheckpointChanges {
*/
const CHECKPOINT_TIMEOUT_MS = 60_000;

/**
* Above this many buckets (a collection-wide estimate), the report ranks a bounded sample of bucket_state
* rather than every bucket, so the request cannot exhaust memory or run unbounded. Below it, the ranking is
* exact.
*/
const BUCKET_SELECTION_SAMPLE_THRESHOLD = 50_000;

/**
* Approximate number of buckets sampled when over {@link BUCKET_SELECTION_SAMPLE_THRESHOLD}. The sample is
* drawn with `$sampleRate`, so the achieved count varies slightly around this.
*/
const BUCKET_SELECTION_SAMPLE_SIZE = 10_000;

/**
* Most bucket_state index entries one report query may scan. Even when sampling fetches few documents, the
* covered index scan and the matched-bucket count still touch every matched index entry once, so past this
* the report fails fast instead of scaling without bound.
*/
const BUCKET_SELECTION_SCAN_MAX = 1_000_000;

export interface TopBucketSelection {
buckets: storage.RankedBucketInput[];
definitions: storage.RankedDefinitionInput[];
/** True if more definitions exist than `definitions` holds ({@link storage.BUCKET_REPORT_DEFINITION_LIMIT}). */
definitionsTruncated: boolean;
totals: storage.BucketReportTotals;
}

/**
* Version-specific aggregation expressions over a bucket_state document, feeding
* {@link MongoSyncBucketStorage.aggregateTopBuckets}.
*/
export interface BucketStateReportExpressions {
/** The bucket's current total operation count. */
operations: mongo.Document;
/** The bucket's current operation-history bytes, as a numeric expression. */
operationBytes: mongo.Document;
/**
* Statistics captured by the bucket's last full compact. Omitted for storage versions that do not record
* them (v1/v2), which limits the report to operation counts.
*/
fullCompact?: {
/** Operation count of the compacted prefix, e.g. `'$last_full_compact.count'`. */
operations: unknown;
/** PUT count of the compacted prefix (the row count as of the compact). */
puts: unknown;
/** When the full compact ran. */
at: unknown;
/** When the scheduled compactor next considers the bucket. */
nextCompactAt: unknown;
};
}

export abstract class MongoSyncBucketStorage
extends BaseObserver<storage.SyncRulesBucketStorageListener>
implements storage.SyncRulesBucketStorage
Expand Down Expand Up @@ -431,6 +486,225 @@ export abstract class MongoSyncBucketStorage
options: CompactInitialReplicationOptions
): Promise<CompactInitialReplicationResults>;

async getBucketReport(options?: storage.GetBucketReportOptions): Promise<storage.BucketReport> {
const limit = storage.resolveBucketReportLimit(options?.limit);
try {
// Everything comes from the pre-aggregated bucket state (one document per bucket, ranked and limited
// in the database): exact operation counts plus the last full compact's statistics, from which the
// row-level fields are derived. The operation history itself is never read.
const { buckets, definitions, definitionsTruncated, totals } = await this.collectTopBuckets(limit);
return storage.assembleBucketReport(buckets, definitions, totals, definitionsTruncated);
} catch (e) {
// Translate a storage query timeout (maxTimeMS) into a specific, retryable error code rather than a
// generic internal error.
throw lib_mongo.mapQueryError(e, 'while building the bucket report');
}
}

/**
* Select the worst-offender buckets (by operation count), the per-definition rollup, and instance-wide
* operation totals from the pre-aggregated bucket state. Ranking and limiting happen in the database, so
* memory stays bounded. Implementations supply their version-specific bucket state collection,
* active-config filter, and stat expressions.
*/
protected abstract collectTopBuckets(limit: number): Promise<TopBucketSelection>;

/**
* Rank buckets by operation count in the database and compute instance-wide operation totals, reading the
* pre-aggregated bucket state. One document per bucket, no scan of bucket data.
*
* For very large bucket sets the candidates are drawn from a bounded sample of the matched `_id` index
* range rather than the whole collection (so the request cannot run unbounded or exhaust memory), and the
* totals are scaled from the sample and flagged estimated. `allowDiskUse: false` makes an over-threshold
* exact attempt fail fast rather than spill to disk and degrade the live instance.
*
* Note: for v1/v2 storage, bucket_state is not backfilled (see models.ts: "only populated by new updates"),
* so buckets that predate bucket_state tracking and have not been updated or compacted since are missing
* here and under-counted. v3 always has bucket_state.
*/
protected async aggregateTopBuckets<T extends { _id: { b: string } }>(
collection: mongo.Collection<T>,
match: mongo.Filter<T>,
limit: number,
exprs: BucketStateReportExpressions
): Promise<TopBucketSelection> {
const { operations, operationBytes, fullCompact } = exprs;
// Bucket names are `<definition>[<serialized parameters>]`, so everything before the first `[` groups a
// bucket into its definition.
const definitionKey = { $arrayElemAt: [{ $split: ['$_id.b', '['] }, 0] };

// Reports are bulk reads: keep them off the primary by using the configured bulk read preference,
// falling back to secondaryPreferred. Staleness does not matter for a report.
const readPreference =
this.readPreference ??
new mongo.ReadPreference('secondaryPreferred', undefined, {
// 90 is the minimum value.
maxStalenessSeconds: 90
});

// estimatedDocumentCount is O(1) but ignores the match filter, so this is an upper bound on the active
// bucket count. That is fine for the sampling decision: over-estimating only switches to sampling sooner.
const estimatedTotalBuckets = await collection.estimatedDocumentCount({ readPreference });

let matchedBuckets: number | null = null;
if (estimatedTotalBuckets > BUCKET_SELECTION_SAMPLE_THRESHOLD) {
// The exact matched-bucket count. `match` is an `_id` range, so this is an index-only scan; it sets
// the sample rate, scales the sampled sums back up, and doubles as the exact totals.bucketCount.
// `limit` caps how many index entries the count may touch: hitting the cap means the instance is past
// what this report is designed to scan, so fail fast rather than read the index without bound.
matchedBuckets = await collection.countDocuments(match, {
maxTimeMS: lib_mongo.db.MONGO_OPERATION_TIMEOUT_MS,
readPreference,
limit: BUCKET_SELECTION_SCAN_MAX + 1
});
if (matchedBuckets > BUCKET_SELECTION_SCAN_MAX) {
throw new ServiceError({
status: 422,
code: ErrorCode.PSYNC_S2001,
description: `Bucket report is not supported on this instance: more than ${BUCKET_SELECTION_SCAN_MAX} buckets match the active sync configuration`
});
}
}
const sampleRate = matchedBuckets == null ? 1 : BUCKET_SELECTION_SAMPLE_SIZE / Math.max(matchedBuckets, 1);
const sampled = sampleRate < 1;

const pipeline: mongo.Document[] = [{ $match: match }];
if (sampled) {
// Sample on the index alone, then fetch only the sampled documents: the range $match plus the _id
// projection is a covered index scan (explain shows docsExamined: 0), $sampleRate keeps roughly
// SAMPLE_SIZE ids, and the self-$lookup fetches just those. Sampling after a plain $match would fetch
// every matched document only to discard most of them.
pipeline.push(
{ $project: { _id: 1 } },
{ $match: { $sampleRate: sampleRate } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid sampling away the worst offenders

When an active configuration has more than 50,000 buckets, this uniform sample is applied before the operation-count sort. At 50,001 buckets, a single pathological bucket has roughly an 80% chance of being omitted, causing both the returned “worst offenders” and the scaled operation total to miss precisely the outlier this endpoint is intended to diagnose. The totals.estimated flag does not make the randomly truncated top-bucket list actionable; determine the top buckets from the full matched set, even if sampling remains necessary for aggregate totals.

Useful? React with 👍 / 👎.

{ $lookup: { from: collection.collectionName, localField: '_id', foreignField: '_id', as: 'doc' } },
{ $unwind: '$doc' },
{ $replaceRoot: { newRoot: '$doc' } }
);
}
// BSON comparison order places every concrete value above null/missing, so this is true exactly when
// the bucket has full-compact statistics.
const hasFullCompact = fullCompact == null ? false : { $gt: [fullCompact.operations, null] };
pipeline.push({
$facet: {
totals: [
{
$group: {
_id: null,
operations: { $sum: operations },
operationBytes: { $sum: operationBytes },
bucketCount: { $sum: 1 }
}
}
],
top: [
{
$project: {
_id: 0,
bucket: '$_id.b',
operations,
operationBytes,
...(fullCompact && {
compactedOperations: { $ifNull: [fullCompact.operations, null] },
compactedPuts: { $ifNull: [fullCompact.puts, null] },
lastFullCompactAt: { $ifNull: [fullCompact.at, null] },
nextCompactAt: { $ifNull: [fullCompact.nextCompactAt, null] }
})
}
},
{ $sort: { operations: -1 } },
{ $limit: limit }
Comment on lines +615 to +616

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the fragmentation tie-break before limiting buckets

When more than limit buckets share the operation count at the cutoff, this database sort selects an arbitrary subset because it considers only operations; assembleBucketReport() applies the documented fragmentation tie-break only after the subset has already been limited. Consequently, a more fragmented tied bucket can be omitted from the worst-offender list. Include the fragmentation tie-break in the aggregation before $limit, or avoid promising that secondary ranking.

Useful? React with 👍 / 👎.

],
definitions: [
{
$group: {
_id: definitionKey,
operations: { $sum: operations },
operationBytes: { $sum: operationBytes },
bucketCount: { $sum: 1 },
...(fullCompact && {
compactedBucketCount: { $sum: { $cond: [hasFullCompact, 1, 0] } },
compactedOperations: { $sum: { $ifNull: [fullCompact.operations, 0] } },
compactedPuts: { $sum: { $ifNull: [fullCompact.puts, 0] } }
})
}
},
{ $sort: { operations: -1 } },
// One past the cap: an extra result only signals that the rollup was truncated.
{ $limit: storage.BUCKET_REPORT_DEFINITION_LIMIT + 1 }
]
}
});

type FacetResult = {
totals: { operations: number; operationBytes: number; bucketCount: number }[];
top: storage.RankedBucketInput[];
definitions: {
_id: string;
operations: number;
operationBytes: number;
bucketCount: number;
compactedBucketCount?: number;
compactedOperations?: number;
compactedPuts?: number;
}[];
};
const [result] = await collection
.aggregate<FacetResult>(pipeline, {
allowDiskUse: false,
maxTimeMS: lib_mongo.db.MONGO_OPERATION_TIMEOUT_MS,
readPreference
})
.toArray();

const rawTotals = result?.totals[0] ?? { operations: 0, operationBytes: 0, bucketCount: 0 };
const buckets = result?.top ?? [];
const rawDefinitions = result?.definitions ?? [];
const definitionsTruncated = rawDefinitions.length > storage.BUCKET_REPORT_DEFINITION_LIMIT;
const mapDefinitions = (scale: number): storage.RankedDefinitionInput[] =>
rawDefinitions.slice(0, storage.BUCKET_REPORT_DEFINITION_LIMIT).map((d) => ({
definition: d._id,
bucketCount: Math.round(d.bucketCount * scale),
operations: Math.round(d.operations * scale),
operationBytes: Math.round(d.operationBytes * scale),
...(fullCompact && {
compactedBucketCount: Math.round((d.compactedBucketCount ?? 0) * scale),
compactedOperations: Math.round((d.compactedOperations ?? 0) * scale),
compactedPuts: Math.round((d.compactedPuts ?? 0) * scale)
})
}));

if (!sampled) {
return {
buckets,
definitions: mapDefinitions(1),
definitionsTruncated,
totals: {
bucketCount: rawTotals.bucketCount,
operations: rawTotals.operations,
operationBytes: rawTotals.operationBytes,
estimated: false
}
};
}

// Scale the sampled sums up to the full matched set, using the exact matched count from above. The
// sample is uniform across buckets, so the per-definition sums scale by the same factor; a definition
// small enough to be missed by the sample entirely is absent. bucketCount itself is exact.
const scale = matchedBuckets! / Math.max(rawTotals.bucketCount, 1);
return {
buckets,
definitions: mapDefinitions(scale),
definitionsTruncated,
totals: {
bucketCount: matchedBuckets!,
operations: Math.round(rawTotals.operations * scale),
operationBytes: Math.round(rawTotals.operationBytes * scale),
estimated: true
}
};
}

/**
* The highest op id persisted for this stream, whether or not covered by a checkpoint.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import { MongoPersistedReplicationStream } from '../MongoPersistedReplicationStr
import {
MongoCheckpointState,
MongoSyncBucketStorage,
MongoSyncBucketStorageOptions
MongoSyncBucketStorageOptions,
TopBucketSelection
} from '../MongoSyncBucketStorage.js';
import {
BucketDataDocumentV1,
Expand Down Expand Up @@ -229,6 +230,30 @@ export class MongoSyncBucketStorageV1 extends MongoSyncBucketStorage {
return result;
}

// For storage v1/v2, bucket state and bucket data are shared collections scoped by group (replication stream).
// v1/v2 bucket_state does not capture full-compact statistics, so the report is limited to operation
// counts: rows, fragmentation and the suggested action are not available.
protected async collectTopBuckets(limit: number): Promise<TopBucketSelection> {
// Range-match on the whole `_id` (g, b) so the {_id} index bounds the scan; a dotted `{'_id.g': ...}`
// match cannot use the compound-object index and would scan the whole collection.
return await this.aggregateTopBuckets(
this.db.bucketStateV1,
{ _id: idPrefixFilter<{ g: number; b: string }>({ g: this.replicationStreamId }, ['b']) },
Comment on lines +239 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include legacy v1/v2 buckets in the report

On upgraded storage-version 1/2 instances, buckets created before bucket_state tracking can be absent from this collection indefinitely: BucketStateDocumentBase explicitly notes at models.ts:138-139 that no migration populated existing data and only new updates create state. Querying only bucketStateV1 therefore omits untouched legacy buckets and their operations while returning estimated: false, contradicting the endpoint's exact-count contract for supported v1/v2 storage; use the bucket-data source or backfill/flag incomplete results.

AGENTS.md reference: AGENTS.md:L70-L77

Useful? React with 👍 / 👎.

limit,
{
operations: {
$add: [{ $ifNull: ['$compacted_state.count', 0] }, { $ifNull: ['$estimate_since_compact.count', 0] }]
},
operationBytes: {
$add: [
{ $toDouble: { $ifNull: ['$compacted_state.bytes', 0] } },
{ $toDouble: { $ifNull: ['$estimate_since_compact.bytes', 0] } }
]
}
}
);
}

protected createMongoParameterCompactor(
checkpoint: InternalOpId,
options: storage.CompactOptions
Expand Down
Loading
Loading