Skip to content

MongoDB sampling estimation

bondi.mongodb.MongoSampleEstimator

Synchronous facade using its borrowed MongoExecutor's existing event loop.

Options and results match AsyncMongoSampleEstimator. Use prepare()/close() or a with block. Close the estimator before closing its executor. No second connection or event loop is created.

Source code in src/bondi/mongodb/estimator.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
class MongoSampleEstimator:
    """Synchronous facade using its borrowed MongoExecutor's existing event loop.

    Options and results match AsyncMongoSampleEstimator. Use prepare()/close()
    or a with block. Close the estimator before closing its executor. No second
    connection or event loop is created.
    """

    def __init__(
        self,
        executor: MongoExecutor,
        target: MongoCollectionRef,
        *,
        sample_size: int | None = None,
        sample_ratio: float | None = None,
        persist: bool = False,
        sample_database: str = "bondi_samples",
        population_limit: int | None = None,
        cleanup_on_exit: bool = True,
    ) -> None:
        if not isinstance(executor, MongoExecutor):
            raise TypeError("executor must be a MongoExecutor")
        executor._check()
        self._executor = executor
        self._estimator = AsyncMongoSampleEstimator(
            executor._executor,
            target,
            sample_size=sample_size,
            sample_ratio=sample_ratio,
            persist=persist,
            sample_database=sample_database,
            population_limit=population_limit,
            cleanup_on_exit=cleanup_on_exit,
        )

    @property
    def population_size(self) -> int | None:
        """Recorded population count, or None before preparation."""
        return self._estimator.population_size

    @property
    def sample_size(self) -> int | None:
        """Effective sample size, or None before preparation."""
        return self._estimator.sample_size

    @property
    def sample_target(self) -> MongoCollectionRef | None:
        """Owned sample collection, or None."""
        return self._estimator.sample_target

    def _call(self, method, *args):
        self._executor._check()

        async def invoke():
            return await getattr(self._estimator, method)(*args)

        return self._executor._submit(invoke, cancel_on_interrupt=method != "close")

    def prepare(self) -> Self:
        """Prepare explicitly; entering a with block also prepares."""
        self._call("prepare")
        return self

    def estimate(self, query: MongoFindQuery) -> CardinalityEstimate:
        """Estimate filter cardinality using the borrowed executor's loop."""
        return self._call("estimate", query)

    def drop_sample(self) -> None:
        """Drop the owned sample, including after close if it was retained."""
        if self.sample_target is not None:
            self._call("drop_sample")

    def close(self) -> None:
        """Close the estimator; repeated calls are harmless."""
        if not self._estimator._closed:
            self._call("close")

    def __enter__(self) -> Self:
        return self.prepare()

    def __exit__(self, exc_type, exc, traceback) -> None:
        self.close()

population_size property

Recorded population count, or None before preparation.

sample_size property

Effective sample size, or None before preparation.

sample_target property

Owned sample collection, or None.

close()

Close the estimator; repeated calls are harmless.

Source code in src/bondi/mongodb/estimator.py
322
323
324
325
def close(self) -> None:
    """Close the estimator; repeated calls are harmless."""
    if not self._estimator._closed:
        self._call("close")

drop_sample()

Drop the owned sample, including after close if it was retained.

Source code in src/bondi/mongodb/estimator.py
317
318
319
320
def drop_sample(self) -> None:
    """Drop the owned sample, including after close if it was retained."""
    if self.sample_target is not None:
        self._call("drop_sample")

estimate(query)

Estimate filter cardinality using the borrowed executor's loop.

Source code in src/bondi/mongodb/estimator.py
313
314
315
def estimate(self, query: MongoFindQuery) -> CardinalityEstimate:
    """Estimate filter cardinality using the borrowed executor's loop."""
    return self._call("estimate", query)

prepare()

Prepare explicitly; entering a with block also prepares.

Source code in src/bondi/mongodb/estimator.py
308
309
310
311
def prepare(self) -> Self:
    """Prepare explicitly; entering a with block also prepares."""
    self._call("prepare")
    return self

bondi.mongodb.AsyncMongoSampleEstimator

Estimate filter cardinality using MongoDB matching semantics.

Construction does no I/O. Call prepare() or enter an async context before estimating. The executor is borrowed and must outlive the estimator. Lifecycle operations must not overlap estimates; inputs and source data should stay stable.

Parameters:

Name Type Description Default
executor AsyncMongoExecutor

Borrowed async executor providing connection and timeouts.

required
target MongoCollectionRef

Source collection; estimate queries must target this collection.

required
sample_size int | None

Positive requested size, capped at population size with a warning.

None
sample_ratio float | None

Alternative fraction in (0, 1]; rounded to nearest integer, with a minimum of one for nonempty populations (warning when adjusted).

None
persist bool

Materialize one reusable sample; otherwise sample anew per estimate.

False
sample_database str

Database for uniquely named, estimator-owned collections.

'bondi_samples'
population_limit int | None

Optional positive prefix limit defining the population.

None
cleanup_on_exit bool

Drop the owned sample on close/context exit. If False, drop_sample() remains available after close while the executor is open.

True

Supplying neither size nor ratio evaluates the full population. Even with persist=True, full-population mode uses the source directly and creates no sample collection. Population size is counted once during preparation. A population_limit selects an unsorted prefix, not a random population.

Source code in src/bondi/mongodb/estimator.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
class AsyncMongoSampleEstimator:
    """Estimate filter cardinality using MongoDB matching semantics.

    Construction does no I/O. Call prepare() or enter an async context before
    estimating. The executor is borrowed and must outlive the estimator. Lifecycle
    operations must not overlap estimates; inputs and source data should stay stable.

    Args:
        executor: Borrowed async executor providing connection and timeouts.
        target: Source collection; estimate queries must target this collection.
        sample_size: Positive requested size, capped at population size with a warning.
        sample_ratio: Alternative fraction in (0, 1]; rounded to nearest integer,
            with a minimum of one for nonempty populations (warning when adjusted).
        persist: Materialize one reusable sample; otherwise sample anew per estimate.
        sample_database: Database for uniquely named, estimator-owned collections.
        population_limit: Optional positive prefix limit defining the population.
        cleanup_on_exit: Drop the owned sample on close/context exit. If False,
            drop_sample() remains available after close while the executor is open.

    Supplying neither size nor ratio evaluates the full population. Even with
    persist=True, full-population mode uses the source directly and creates no
    sample collection. Population size is counted once during preparation. A
    population_limit selects an unsorted prefix, not a random population.
    """

    def __init__(
        self,
        executor: AsyncMongoExecutor,
        target: MongoCollectionRef,
        *,
        sample_size: int | None = None,
        sample_ratio: float | None = None,
        persist: bool = False,
        sample_database: str = "bondi_samples",
        population_limit: int | None = None,
        cleanup_on_exit: bool = True,
    ) -> None:
        if not isinstance(executor, AsyncMongoExecutor):
            raise TypeError("executor must be an AsyncMongoExecutor")
        if not isinstance(target, MongoCollectionRef):
            raise TypeError("target must be a MongoCollectionRef")
        if sample_size is not None and sample_ratio is not None:
            raise ValueError("Use only one of sample_size or sample_ratio")
        for name, value in (("sample_size", sample_size), ("population_limit", population_limit)):
            if value is not None and (type(value) is not int or value <= 0):
                raise ValueError(f"{name} must be a positive integer")
        if sample_ratio is not None and (
            type(sample_ratio) not in (int, float)
            or not math.isfinite(sample_ratio)
            or not 0 < sample_ratio <= 1
        ):
            raise ValueError("sample_ratio must be finite and in (0, 1]")
        if type(persist) is not bool or type(cleanup_on_exit) is not bool:
            raise TypeError("persist and cleanup_on_exit must be bool")
        MongoCollectionRef(sample_database, "validate")
        self._executor = executor
        self._target = target
        self._requested_size = sample_size
        self._ratio = sample_ratio
        self._persist = persist
        self._sample_database = sample_database
        self._limit = population_limit
        self._cleanup = cleanup_on_exit
        self._population_size: int | None = None
        self._sample_size: int | None = None
        self._sample_target: MongoCollectionRef | None = None
        self._prepared = False
        self._closed = False

    @property
    def population_size(self) -> int | None:
        """Recorded population count, or None before preparation."""
        return self._population_size

    @property
    def sample_size(self) -> int | None:
        """Effective size; actual materialized count when persisted, None before preparation."""
        return self._sample_size

    @property
    def sample_target(self) -> MongoCollectionRef | None:
        """Owned sample collection, or None when no materialized sample exists."""
        return self._sample_target

    def _prefix(self) -> list[dict]:
        return [{"$limit": self._limit}] if self._limit is not None else []

    def _check_open(self) -> None:
        if self._closed:
            raise RuntimeError("Estimator is closed")
        self._executor._check_open()

    async def _run(self, target, pipeline):
        result = await self._executor.run(
            MongoAggregateQuery(target, pipeline), collect_results=True
        )
        return result.documents

    async def prepare(self) -> Self:
        """Count the population and optionally materialize a sample; idempotent.

        Adjustment warnings are UserWarning. Failed sample creation attempts
        cleanup even when cleanup_on_exit=False. Database errors propagate.
        """
        self._check_open()
        if self._prepared:
            return self
        if self._sample_target is not None:
            raise RuntimeError("Call drop_sample() to clean up the previous failed preparation")
        rows = await self._run(self._target, self._prefix() + [{"$count": "count"}])
        population = rows[0]["count"] if rows else 0
        size = population
        if self._requested_size is not None:
            size = min(self._requested_size, population)
            if self._requested_size > population:
                warnings.warn(
                    f"Requested sample_size={self._requested_size} exceeds "
                    f"population_size={population}; using all {population} documents.",
                    UserWarning,
                    stacklevel=2,
                )
        elif self._ratio is not None:
            size = round(population * self._ratio)
            if population and size == 0:
                size = 1
                warnings.warn(
                    f"sample_ratio={self._ratio} rounds to zero documents for "
                    f"population_size={population}; using one document.",
                    UserWarning,
                    stacklevel=2,
                )
        self._population_size = population
        self._sample_size = size
        if self._persist and 0 < size < population:
            target = MongoCollectionRef(self._sample_database, f"sample_{uuid4().hex}")
            self._sample_target = target
            try:
                # UUID names keep ownership separate from source and other estimators.
                await self._run(
                    self._target,
                    self._prefix()
                    + [
                        {"$sample": {"size": size}},
                        {"$out": {"db": target.database, "coll": target.collection}},
                    ],
                )
                rows = await self._run(target, [{"$count": "count"}])
                self._sample_size = rows[0]["count"] if rows else 0
                if not self._sample_size:
                    raise RuntimeError("Sample became empty during preparation; source changed")
            except BaseException:
                await self.drop_sample()
                raise
        self._prepared = True
        return self

    async def estimate(self, query: MongoFindQuery) -> CardinalityEstimate:
        """Estimate filter matches before sort/projection/skip/limit.

        Returns sample matches and actual evaluated sample size. Fresh samples
        use a facet so numerator and denominator describe the same draw. Source
        mutation can invalidate the recorded population; no snapshot is promised.
        """
        self._check_open()
        if not self._prepared:
            raise RuntimeError("Call prepare() or use async with before estimating")
        if not isinstance(query, MongoFindQuery):
            raise TypeError("query must be a MongoFindQuery")
        query.validate()
        if query.target != self._target:
            raise ValueError("query target must match the estimator's source collection")
        population = self._population_size
        if population == 0:
            return CardinalityEstimate(0.0, 0, 0, 0)
        if self._sample_target is not None:
            rows = await self._run(
                self._sample_target,
                [
                    {"$match": query.filter},
                    {"$count": "count"},
                ],
            )
            matches = rows[0]["count"] if rows else 0
            size = self._sample_size
        else:
            pipeline = self._prefix()
            if self._sample_size < population:
                pipeline.append({"$sample": {"size": self._sample_size}})
            pipeline.append(
                {
                    "$facet": {
                        "sample": [{"$count": "count"}],
                        "matches": [{"$match": query.filter}, {"$count": "count"}],
                    }
                }
            )
            rows = await self._run(self._target, pipeline)
            counts = rows[0] if rows else {"sample": [], "matches": []}
            size = counts["sample"][0]["count"] if counts["sample"] else 0
            matches = counts["matches"][0]["count"] if counts["matches"] else 0
            if size == 0:
                raise RuntimeError("Population became empty; prepare a new estimator")
        return CardinalityEstimate(matches / size * population, population, matches, size)

    async def drop_sample(self) -> None:
        """Drop only this estimator's sample; idempotent, including after close.

        Dropping an active sample invalidates preparation. Call prepare() again
        to create a new sample. The borrowed executor must remain open.
        """
        if self._sample_target is None:
            return
        self._executor._check_open()
        target = self._sample_target
        with self._executor._timeout():
            await self._executor.client[target.database].drop_collection(target.collection)
        self._sample_target = None
        self._prepared = False

    async def close(self) -> None:
        """Clean up the sample by default; never close the borrowed executor."""
        if self._closed:
            return
        if self._cleanup:
            await self.drop_sample()
        self._closed = True

    async def __aenter__(self) -> Self:
        return await self.prepare()

    async def __aexit__(self, exc_type, exc, traceback) -> None:
        await self.close()

population_size property

Recorded population count, or None before preparation.

sample_size property

Effective size; actual materialized count when persisted, None before preparation.

sample_target property

Owned sample collection, or None when no materialized sample exists.

close() async

Clean up the sample by default; never close the borrowed executor.

Source code in src/bondi/mongodb/estimator.py
235
236
237
238
239
240
241
async def close(self) -> None:
    """Clean up the sample by default; never close the borrowed executor."""
    if self._closed:
        return
    if self._cleanup:
        await self.drop_sample()
    self._closed = True

drop_sample() async

Drop only this estimator's sample; idempotent, including after close.

Dropping an active sample invalidates preparation. Call prepare() again to create a new sample. The borrowed executor must remain open.

Source code in src/bondi/mongodb/estimator.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
async def drop_sample(self) -> None:
    """Drop only this estimator's sample; idempotent, including after close.

    Dropping an active sample invalidates preparation. Call prepare() again
    to create a new sample. The borrowed executor must remain open.
    """
    if self._sample_target is None:
        return
    self._executor._check_open()
    target = self._sample_target
    with self._executor._timeout():
        await self._executor.client[target.database].drop_collection(target.collection)
    self._sample_target = None
    self._prepared = False

estimate(query) async

Estimate filter matches before sort/projection/skip/limit.

Returns sample matches and actual evaluated sample size. Fresh samples use a facet so numerator and denominator describe the same draw. Source mutation can invalidate the recorded population; no snapshot is promised.

Source code in src/bondi/mongodb/estimator.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
async def estimate(self, query: MongoFindQuery) -> CardinalityEstimate:
    """Estimate filter matches before sort/projection/skip/limit.

    Returns sample matches and actual evaluated sample size. Fresh samples
    use a facet so numerator and denominator describe the same draw. Source
    mutation can invalidate the recorded population; no snapshot is promised.
    """
    self._check_open()
    if not self._prepared:
        raise RuntimeError("Call prepare() or use async with before estimating")
    if not isinstance(query, MongoFindQuery):
        raise TypeError("query must be a MongoFindQuery")
    query.validate()
    if query.target != self._target:
        raise ValueError("query target must match the estimator's source collection")
    population = self._population_size
    if population == 0:
        return CardinalityEstimate(0.0, 0, 0, 0)
    if self._sample_target is not None:
        rows = await self._run(
            self._sample_target,
            [
                {"$match": query.filter},
                {"$count": "count"},
            ],
        )
        matches = rows[0]["count"] if rows else 0
        size = self._sample_size
    else:
        pipeline = self._prefix()
        if self._sample_size < population:
            pipeline.append({"$sample": {"size": self._sample_size}})
        pipeline.append(
            {
                "$facet": {
                    "sample": [{"$count": "count"}],
                    "matches": [{"$match": query.filter}, {"$count": "count"}],
                }
            }
        )
        rows = await self._run(self._target, pipeline)
        counts = rows[0] if rows else {"sample": [], "matches": []}
        size = counts["sample"][0]["count"] if counts["sample"] else 0
        matches = counts["matches"][0]["count"] if counts["matches"] else 0
        if size == 0:
            raise RuntimeError("Population became empty; prepare a new estimator")
    return CardinalityEstimate(matches / size * population, population, matches, size)

prepare() async

Count the population and optionally materialize a sample; idempotent.

Adjustment warnings are UserWarning. Failed sample creation attempts cleanup even when cleanup_on_exit=False. Database errors propagate.

Source code in src/bondi/mongodb/estimator.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
async def prepare(self) -> Self:
    """Count the population and optionally materialize a sample; idempotent.

    Adjustment warnings are UserWarning. Failed sample creation attempts
    cleanup even when cleanup_on_exit=False. Database errors propagate.
    """
    self._check_open()
    if self._prepared:
        return self
    if self._sample_target is not None:
        raise RuntimeError("Call drop_sample() to clean up the previous failed preparation")
    rows = await self._run(self._target, self._prefix() + [{"$count": "count"}])
    population = rows[0]["count"] if rows else 0
    size = population
    if self._requested_size is not None:
        size = min(self._requested_size, population)
        if self._requested_size > population:
            warnings.warn(
                f"Requested sample_size={self._requested_size} exceeds "
                f"population_size={population}; using all {population} documents.",
                UserWarning,
                stacklevel=2,
            )
    elif self._ratio is not None:
        size = round(population * self._ratio)
        if population and size == 0:
            size = 1
            warnings.warn(
                f"sample_ratio={self._ratio} rounds to zero documents for "
                f"population_size={population}; using one document.",
                UserWarning,
                stacklevel=2,
            )
    self._population_size = population
    self._sample_size = size
    if self._persist and 0 < size < population:
        target = MongoCollectionRef(self._sample_database, f"sample_{uuid4().hex}")
        self._sample_target = target
        try:
            # UUID names keep ownership separate from source and other estimators.
            await self._run(
                self._target,
                self._prefix()
                + [
                    {"$sample": {"size": size}},
                    {"$out": {"db": target.database, "coll": target.collection}},
                ],
            )
            rows = await self._run(target, [{"$count": "count"}])
            self._sample_size = rows[0]["count"] if rows else 0
            if not self._sample_size:
                raise RuntimeError("Sample became empty during preparation; source changed")
        except BaseException:
            await self.drop_sample()
            raise
    self._prepared = True
    return self